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
This commit is contained in:
commit
fa7d848f52
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -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
|
||||
33
Package.swift
Normal file
33
Package.swift
Normal file
@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
207
README.md
Normal file
207
README.md
Normal file
@ -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+
|
||||
305
Sources/Bedrock/Audio/SoundManager.swift
Normal file
305
Sources/Bedrock/Audio/SoundManager.swift
Normal file
@ -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<S: AppSound>(_ 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<S: AppSound>(_ 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
|
||||
}
|
||||
47
Sources/Bedrock/Bedrock.swift
Normal file
47
Sources/Bedrock/Bedrock.swift
Normal file
@ -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"
|
||||
}
|
||||
14
Sources/Bedrock/Exports.swift
Normal file
14
Sources/Bedrock/Exports.swift
Normal file
@ -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
|
||||
141
Sources/Bedrock/Models/OnboardingState.swift
Normal file
141
Sources/Bedrock/Models/OnboardingState.swift
Normal file
@ -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<String> = []
|
||||
|
||||
/// Hint keys registered by the app for automatic skipping.
|
||||
private var registeredHintKeys: Set<String> = []
|
||||
|
||||
// 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<String>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
1
Sources/Bedrock/Resources/Localizable.xcstrings
Normal file
1
Sources/Bedrock/Resources/Localizable.xcstrings
Normal file
@ -0,0 +1 @@
|
||||
{"sourceLanguage":"en","strings":{},"version":"1.0"}
|
||||
415
Sources/Bedrock/Storage/CloudSyncManager.swift
Normal file
415
Sources/Bedrock/Storage/CloudSyncManager.swift
Normal file
@ -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<UserData>()
|
||||
///
|
||||
/// var userData: UserData {
|
||||
/// syncManager.data
|
||||
/// }
|
||||
///
|
||||
/// func saveProgress() {
|
||||
/// syncManager.update { data in
|
||||
/// data.level += 1
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CloudSyncManager<T: PersistableData> {
|
||||
|
||||
// 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")
|
||||
}
|
||||
180
Sources/Bedrock/Theme/ColorProtocols.swift
Normal file
180
Sources/Bedrock/Theme/ColorProtocols.swift
Normal file
@ -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
|
||||
}
|
||||
137
Sources/Bedrock/Theme/Colors.swift
Normal file
137
Sources/Bedrock/Theme/Colors.swift
Normal file
@ -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
|
||||
}
|
||||
275
Sources/Bedrock/Theme/Design.swift
Normal file
275
Sources/Bedrock/Theme/Design.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
148
Sources/Bedrock/Utilities/DeviceInfo.swift
Normal file
148
Sources/Bedrock/Utilities/DeviceInfo.swift
Normal file
@ -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
|
||||
68
Sources/Bedrock/Views/Debug/DebugBorderModifier.swift
Normal file
68
Sources/Bedrock/Views/Debug/DebugBorderModifier.swift
Normal file
@ -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)
|
||||
}
|
||||
106
Sources/Bedrock/Views/Effects/ConfettiView.swift
Normal file
106
Sources/Bedrock/Views/Effects/ConfettiView.swift
Normal file
@ -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..<count, id: \.self) { _ in
|
||||
ConfettiPiece(
|
||||
color: colors.randomElement() ?? .yellow,
|
||||
containerSize: geometry.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ConfettiView()
|
||||
}
|
||||
}
|
||||
81
Sources/Bedrock/Views/Effects/PulsingModifier.swift
Normal file
81
Sources/Bedrock/Views/Effects/PulsingModifier.swift
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// PulsingModifier.swift
|
||||
// Bedrock
|
||||
//
|
||||
// Visual hint modifier that pulses to draw attention.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Modifier that adds a pulsing animation to draw attention to interactive elements.
|
||||
///
|
||||
/// Use this to highlight buttons, actions, or any UI element that needs user attention.
|
||||
public struct PulsingModifier: ViewModifier {
|
||||
let isActive: Bool
|
||||
let color: Color
|
||||
let scale: CGFloat
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@State private var animationAmount: CGFloat = 1.0
|
||||
|
||||
public func body(content: Content) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
611
Sources/Bedrock/Views/Settings/SettingsComponents.swift
Normal file
611
Sources/Bedrock/Views/Settings/SettingsComponents.swift
Normal file
@ -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<Bool>,
|
||||
accentColor: Color = .Accent.primary
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self._isOn = isOn
|
||||
self.accentColor = accentColor
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
.tint(accentColor)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Volume Picker
|
||||
|
||||
/// A volume slider with speaker icons.
|
||||
///
|
||||
/// Use this for audio volume or similar 0-1 range settings.
|
||||
public struct VolumePicker: View {
|
||||
/// The label for the picker.
|
||||
public let label: String
|
||||
|
||||
/// Binding to the volume level (0.0 to 1.0).
|
||||
@Binding public var volume: Float
|
||||
|
||||
/// The accent color for the slider.
|
||||
public let accentColor: Color
|
||||
|
||||
/// Creates a volume picker.
|
||||
/// - Parameters:
|
||||
/// - label: The label text (default: "Volume").
|
||||
/// - volume: Binding to volume (0.0-1.0).
|
||||
/// - accentColor: The accent color (default: primary accent).
|
||||
public init(
|
||||
label: String = "Volume",
|
||||
volume: Binding<Float>,
|
||||
accentColor: Color = .Accent.primary
|
||||
) {
|
||||
self.label = label
|
||||
self._volume = volume
|
||||
self.accentColor = accentColor
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(volume * 100))%")
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: "speaker.fill")
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Slider(value: $volume, in: 0...1, step: 0.1)
|
||||
.tint(accentColor)
|
||||
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Segmented Picker
|
||||
|
||||
/// A horizontal segmented picker with capsule-style buttons.
|
||||
///
|
||||
/// Use this for selecting from a small number of options (2-4).
|
||||
///
|
||||
/// ```swift
|
||||
/// SegmentedPicker(
|
||||
/// title: "Theme",
|
||||
/// options: [("Light", "light"), ("Dark", "dark"), ("System", "system")],
|
||||
/// selection: $theme
|
||||
/// )
|
||||
/// ```
|
||||
public struct SegmentedPicker<T: Equatable>: View {
|
||||
/// The title/label for the picker.
|
||||
public let title: String
|
||||
|
||||
/// The available options as (label, value) pairs.
|
||||
public let options: [(String, T)]
|
||||
|
||||
/// Binding to the selected value.
|
||||
@Binding public var selection: T
|
||||
|
||||
/// The accent color for the selected button.
|
||||
public let accentColor: Color
|
||||
|
||||
/// Creates a segmented picker.
|
||||
/// - Parameters:
|
||||
/// - title: The title label.
|
||||
/// - options: Array of (label, value) tuples.
|
||||
/// - selection: Binding to selected value.
|
||||
/// - accentColor: The accent color (default: primary accent).
|
||||
public init(
|
||||
title: String,
|
||||
options: [(String, T)],
|
||||
selection: Binding<T>,
|
||||
accentColor: Color = .Accent.primary
|
||||
) {
|
||||
self.title = title
|
||||
self.options = options
|
||||
self._selection = selection
|
||||
self.accentColor = accentColor
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
ForEach(options.indices, id: \.self) { index in
|
||||
let option = options[index]
|
||||
Button {
|
||||
selection = option.1
|
||||
} label: {
|
||||
Text(option.0)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong))
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selectable Row
|
||||
|
||||
/// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
|
||||
///
|
||||
/// Use this for settings pickers, option lists, or any selectable item.
|
||||
///
|
||||
/// ```swift
|
||||
/// SelectableRow(
|
||||
/// title: "Premium",
|
||||
/// subtitle: "Unlock all features",
|
||||
/// isSelected: plan == .premium
|
||||
/// ) {
|
||||
/// plan = .premium
|
||||
/// }
|
||||
/// ```
|
||||
public struct SelectableRow<Badge: View>: View {
|
||||
/// The main title text.
|
||||
public let title: String
|
||||
|
||||
/// The subtitle/description text.
|
||||
public let subtitle: String
|
||||
|
||||
/// Whether this row is currently selected.
|
||||
public let isSelected: Bool
|
||||
|
||||
/// Optional badge view.
|
||||
public let badge: Badge?
|
||||
|
||||
/// The accent color for selection highlighting.
|
||||
public let accentColor: Color
|
||||
|
||||
/// Action when tapped.
|
||||
public let action: () -> Void
|
||||
|
||||
/// Creates a selectable row.
|
||||
/// - Parameters:
|
||||
/// - title: The main title.
|
||||
/// - subtitle: The subtitle description.
|
||||
/// - isSelected: Whether this row is selected.
|
||||
/// - accentColor: Color for selection (default: primary accent).
|
||||
/// - badge: Optional badge view.
|
||||
/// - action: Action when tapped.
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
isSelected: Bool,
|
||||
accentColor: Color = .Accent.primary,
|
||||
@ViewBuilder badge: () -> Badge? = { nil as EmptyView? },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.isSelected = isSelected
|
||||
self.accentColor = accentColor
|
||||
self.badge = badge()
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let badge = badge {
|
||||
badge
|
||||
}
|
||||
|
||||
SelectionIndicator(isSelected: isSelected, accentColor: accentColor)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(isSelected ? accentColor.opacity(Design.Opacity.subtle) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.strokeBorder(
|
||||
isSelected ? accentColor.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle),
|
||||
lineWidth: Design.LineWidth.thin
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience initializer for rows without a badge
|
||||
extension SelectableRow where Badge == EmptyView {
|
||||
/// Creates a selectable row without a badge.
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
isSelected: Bool,
|
||||
accentColor: Color = .Accent.primary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.isSelected = isSelected
|
||||
self.accentColor = accentColor
|
||||
self.badge = nil
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Indicator
|
||||
|
||||
/// A circle indicator that shows selected (checkmark) or unselected (outline) state.
|
||||
public struct SelectionIndicator: View {
|
||||
/// Whether the item is selected.
|
||||
public let isSelected: Bool
|
||||
|
||||
/// The accent color for the checkmark.
|
||||
public let accentColor: Color
|
||||
|
||||
/// The size of the indicator.
|
||||
public let size: CGFloat
|
||||
|
||||
/// Creates a selection indicator.
|
||||
/// - Parameters:
|
||||
/// - isSelected: Whether selected.
|
||||
/// - accentColor: Color for checkmark (default: primary accent).
|
||||
/// - size: Size of the indicator (default: checkmark size from design).
|
||||
public init(
|
||||
isSelected: Bool,
|
||||
accentColor: Color = .Accent.primary,
|
||||
size: CGFloat = Design.Size.checkmark
|
||||
) {
|
||||
self.isSelected = isSelected
|
||||
self.accentColor = accentColor
|
||||
self.size = size
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(accentColor)
|
||||
} else {
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium)
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Badge Pill
|
||||
|
||||
/// A capsule-shaped badge for displaying short text values.
|
||||
///
|
||||
/// Use this to highlight values, tags, or status indicators.
|
||||
public struct BadgePill: View {
|
||||
/// The text to display in the badge.
|
||||
public let text: String
|
||||
|
||||
/// Whether the parent row is selected.
|
||||
public let isSelected: Bool
|
||||
|
||||
/// The accent color.
|
||||
public let accentColor: Color
|
||||
|
||||
/// Creates a badge pill.
|
||||
/// - Parameters:
|
||||
/// - text: The badge text.
|
||||
/// - isSelected: Whether the parent row is selected.
|
||||
/// - accentColor: Color for the badge (default: primary accent).
|
||||
public init(
|
||||
text: String,
|
||||
isSelected: Bool = false,
|
||||
accentColor: Color = .Accent.primary
|
||||
) {
|
||||
self.text = text
|
||||
self.isSelected = isSelected
|
||||
self.accentColor = accentColor
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(isSelected ? .black : accentColor)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isSelected ? accentColor : accentColor.opacity(Design.Opacity.hint))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Section Header
|
||||
|
||||
/// A section header for settings screens.
|
||||
public struct SettingsSectionHeader: View {
|
||||
/// The section title.
|
||||
public let title: String
|
||||
|
||||
/// Optional system image name.
|
||||
public let systemImage: String?
|
||||
|
||||
/// 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<Accessory: View>: View {
|
||||
/// The row icon (SF Symbol name).
|
||||
public let systemImage: String
|
||||
|
||||
/// The row title.
|
||||
public let title: String
|
||||
|
||||
/// Optional value text.
|
||||
public let value: String?
|
||||
|
||||
/// The icon background color.
|
||||
public let iconColor: Color
|
||||
|
||||
/// Optional accessory view.
|
||||
public let accessory: Accessory?
|
||||
|
||||
/// Action when tapped.
|
||||
public let action: () -> Void
|
||||
|
||||
/// Creates a settings row.
|
||||
public init(
|
||||
systemImage: String,
|
||||
title: String,
|
||||
value: String? = nil,
|
||||
iconColor: Color = .Accent.primary,
|
||||
@ViewBuilder accessory: () -> Accessory? = { nil as EmptyView? },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.iconColor = iconColor
|
||||
self.accessory = accessory()
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: Design.BaseFontSize.medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall)
|
||||
.background(iconColor.opacity(Design.Opacity.heavy))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.medium))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
if let accessory {
|
||||
accessory
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.background(Color.Surface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience initializer for rows without accessory
|
||||
extension SettingsRow where Accessory == EmptyView {
|
||||
/// Creates a settings row without an accessory.
|
||||
public init(
|
||||
systemImage: String,
|
||||
title: String,
|
||||
value: String? = nil,
|
||||
iconColor: Color = .Accent.primary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.iconColor = iconColor
|
||||
self.accessory = nil
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush")
|
||||
|
||||
// Selectable rows
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
SelectableRow(
|
||||
title: "Light",
|
||||
subtitle: "Always use light mode",
|
||||
isSelected: true,
|
||||
action: {}
|
||||
)
|
||||
|
||||
SelectableRow(
|
||||
title: "Dark",
|
||||
subtitle: "Always use dark mode",
|
||||
isSelected: false,
|
||||
action: {}
|
||||
)
|
||||
|
||||
SelectableRow(
|
||||
title: "Premium",
|
||||
subtitle: "Unlock all features",
|
||||
isSelected: false,
|
||||
badge: { BadgePill(text: "$9.99", isSelected: false) },
|
||||
action: {}
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: "Preferences", systemImage: "gearshape")
|
||||
|
||||
SettingsToggle(
|
||||
title: "Sound Effects",
|
||||
subtitle: "Play sounds for events",
|
||||
isOn: .constant(true)
|
||||
)
|
||||
|
||||
SegmentedPicker(
|
||||
title: "Animation Speed",
|
||||
options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")],
|
||||
selection: .constant("normal")
|
||||
)
|
||||
|
||||
VolumePicker(volume: .constant(0.8))
|
||||
|
||||
SettingsSectionHeader(title: "About", systemImage: "info.circle")
|
||||
|
||||
SettingsRow(
|
||||
systemImage: "star.fill",
|
||||
title: "Rate App",
|
||||
iconColor: .Status.warning,
|
||||
action: {}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
systemImage: "envelope.fill",
|
||||
title: "Contact Us",
|
||||
value: "support@example.com",
|
||||
iconColor: .Status.info,
|
||||
action: {}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color.Surface.overlay)
|
||||
}
|
||||
64
Tests/BedrockTests/BedrockTests.swift
Normal file
64
Tests/BedrockTests/BedrockTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user