Merge develop into master: add SettingsSlider and nonisolated fix

This commit is contained in:
Matt Bruce 2026-01-04 17:34:23 -06:00
commit b7f24c44c9
28 changed files with 3381 additions and 628 deletions

View File

@ -67,10 +67,10 @@ Bedrock includes neutral default colors that work out of the box:
```swift
Text("Primary Text")
.foregroundStyle(Color.Text.primary)
.foregroundStyle(Color.TextColors.primary)
Text("Secondary Text")
.foregroundStyle(Color.Text.secondary)
.foregroundStyle(Color.TextColors.secondary)
VStack { }
.background(Color.Surface.primary)

View File

@ -10,7 +10,7 @@ import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
@_implementationOnly import UIKit
#endif
// MARK: - Sound Protocol

View File

@ -0,0 +1,231 @@
//
// AppIconView.swift
// Bedrock
//
// A reusable app icon design that can be customized for any app.
// Render this view to an image for use as your app icon.
//
import SwiftUI
/// Configuration for the app icon appearance.
public struct AppIconConfig: Sendable {
public let title: String
public let subtitle: String?
public let iconSymbol: String
public let primaryColor: Color
public let secondaryColor: Color
public let accentColor: Color
public init(
title: String,
subtitle: String? = nil,
iconSymbol: String,
primaryColor: Color = Color(red: 0.15, green: 0.20, blue: 0.30),
secondaryColor: Color = Color(red: 0.08, green: 0.10, blue: 0.18),
accentColor: Color = Color(red: 0.4, green: 0.7, blue: 1.0)
) {
self.title = title
self.subtitle = subtitle
self.iconSymbol = iconSymbol
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
self.accentColor = accentColor
}
// MARK: - Example Configuration (for previews only)
/// Example configuration for Bedrock previews.
/// Apps should define their own configs in `BrandingConfig.swift`.
public static let example = AppIconConfig(
title: "MY APP",
iconSymbol: "star.fill"
)
}
/// A customizable app icon view for any app.
/// Render this view to create your app icon assets.
///
/// **Important**: This view generates a full-bleed square icon. iOS applies its own
/// superellipse mask, so decorative borders are inset to avoid clipping at the edges.
public struct AppIconView: View {
let config: AppIconConfig
let size: CGFloat
public init(config: AppIconConfig, size: CGFloat = 1024) {
self.config = config
self.size = size
}
// Size calculations
private var iconSize: CGFloat { size * 0.35 }
private var subtitleSize: CGFloat { size * 0.25 }
/// Dynamic title size based on text length.
/// Shorter titles get larger fonts, longer titles shrink to fit within the border.
private var titleSize: CGFloat {
let baseSize = size * 0.12
let length = config.title.count
// Scale factor: full size for 6 chars, progressively smaller for longer
let scaleFactor: CGFloat = switch length {
case ...6: 1.0 // "SELFIE", "CAMERA"
case 7: 0.95 // "WEATHER"
case 8: 0.85 // "SETTINGS"
case 9: 0.75 // "MESSENGER"
default: 0.65 // Very long titles
}
return baseSize * scaleFactor
}
public var body: some View {
ZStack {
// Background gradient - full bleed, no rounded corners
// iOS will apply its own superellipse mask
Rectangle()
.fill(
LinearGradient(
colors: [config.primaryColor, config.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
// Subtle pattern overlay
DotPatternOverlay(size: size)
.opacity(0.06)
// Content
VStack(spacing: size * 0.03) {
// Icon symbol
Image(systemName: config.iconSymbol)
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(
LinearGradient(
colors: [config.accentColor, config.accentColor.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .black.opacity(0.3), radius: size * 0.02, y: size * 0.01)
// Subtitle
if let subtitle = config.subtitle {
Text(subtitle)
.font(.system(size: subtitleSize, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [config.accentColor, config.accentColor.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .black.opacity(0.5), radius: size * 0.01)
}
// Title
Text(config.title)
.font(.system(size: titleSize, weight: .black, design: .rounded))
.tracking(size * 0.005)
.foregroundStyle(
LinearGradient(
colors: [.white, .white.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .black.opacity(0.5), radius: size * 0.01)
}
}
.frame(width: size, height: size)
}
}
/// Dot pattern overlay for the icon background.
private struct DotPatternOverlay: View {
let size: CGFloat
private var spacing: CGFloat { size * 0.08 }
private var dotRadius: CGFloat { size * 0.012 }
var body: some View {
Canvas { context, canvasSize in
let rows = Int(canvasSize.height / spacing) + 1
let cols = Int(canvasSize.width / spacing) + 1
for row in 0..<rows {
for col in 0..<cols {
let offset: CGFloat = row % 2 == 0 ? 0 : spacing / 2
let x = CGFloat(col) * spacing + offset
let y = CGFloat(row) * spacing
let dot = Path(ellipseIn: CGRect(
x: x - dotRadius,
y: y - dotRadius,
width: dotRadius * 2,
height: dotRadius * 2
))
context.fill(dot, with: .color(.white))
}
}
}
}
}
// MARK: - Previews
#Preview("App Icon") {
AppIconView(config: .example, size: 512)
.clipShape(.rect(cornerRadius: 512 * 0.22))
.padding()
.background(Color.gray)
}
#Preview("Title Scaling") {
let shortTitle = AppIconConfig(title: "CAMERA", iconSymbol: "camera.fill")
let mediumTitle = AppIconConfig(title: "SETTINGS", iconSymbol: "gearshape.fill")
let longTitle = AppIconConfig(
title: "MESSENGER",
subtitle: "PRO",
iconSymbol: "message.fill",
primaryColor: Color(red: 0.20, green: 0.45, blue: 0.70),
secondaryColor: Color(red: 0.10, green: 0.25, blue: 0.45)
)
return HStack(spacing: 20) {
VStack {
AppIconView(config: shortTitle, size: 180)
.clipShape(.rect(cornerRadius: 180 * 0.22))
Text("6 chars").font(.caption)
}
VStack {
AppIconView(config: mediumTitle, size: 180)
.clipShape(.rect(cornerRadius: 180 * 0.22))
Text("8 chars").font(.caption)
}
VStack {
AppIconView(config: longTitle, size: 180)
.clipShape(.rect(cornerRadius: 180 * 0.22))
Text("9 chars").font(.caption)
}
}
.padding()
.background(Color.gray)
}
#Preview("All Sizes") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 20) {
ForEach([180, 120, 87, 60, 40], id: \.self) { size in
VStack {
AppIconView(config: .example, size: CGFloat(size))
.clipShape(.rect(cornerRadius: CGFloat(size) * 0.22))
Text("\(size)px").font(.caption)
}
}
}
.padding()
.background(Color.gray)
}

View File

@ -0,0 +1,64 @@
//
// AppLaunchView.swift
// Bedrock
//
// A wrapper view that shows an animated launch screen before transitioning
// to the main app content. Use this to create seamless animated launches.
//
import SwiftUI
/// A wrapper that shows an animated launch screen before the main content.
///
/// Usage:
/// ```swift
/// AppLaunchView(config: .baccarat) {
/// ContentView()
/// }
/// ```
public struct AppLaunchView<Content: View>: View {
let config: LaunchScreenConfig
let content: () -> Content
@State private var showLaunchScreen = true
/// Creates a launch wrapper.
/// - Parameters:
/// - config: The launch screen configuration.
/// - content: The main app content to show after the launch animation.
public init(
config: LaunchScreenConfig,
@ViewBuilder content: @escaping () -> Content
) {
self.config = config
self.content = content
}
public var body: some View {
ZStack {
// Main content (always rendered underneath)
content()
// Launch screen overlay
if showLaunchScreen {
LaunchScreenView(config: config)
.transition(.opacity)
.zIndex(1)
}
}
.task {
// Wait for launch animation to complete, then fade out
try? await Task.sleep(for: .seconds(2.0))
withAnimation(.easeOut(duration: 0.5)) {
showLaunchScreen = false
}
}
}
}
#Preview {
AppLaunchView(config: .example) {
Text("Main App Content")
.font(.largeTitle)
}
}

View File

@ -0,0 +1,569 @@
# Bedrock Branding Implementation Guide
A comprehensive guide to implementing the Bedrock branding system (app icon and launch screen) in your iOS app.
## Table of Contents
1. [Overview](#overview)
2. [What's Included](#whats-included)
3. [Step 1: Create BrandingConfig.swift](#step-1-create-brandingconfigswift)
4. [Step 2: Add Launch Screen to App Entry Point](#step-2-add-launch-screen-to-app-entry-point)
5. [Step 3: Set Launch Screen Background Color](#step-3-set-launch-screen-background-color)
6. [Step 4: Add Branding Tools to Settings (Optional)](#step-4-add-branding-tools-to-settings-optional)
7. [Step 5: Generate Your App Icon](#step-5-generate-your-app-icon)
8. [Step 6: Add Icon to Xcode Assets](#step-6-add-icon-to-xcode-assets)
9. [Configuration Reference](#configuration-reference)
10. [Complete Example](#complete-example)
11. [Troubleshooting](#troubleshooting)
---
## Overview
The Bedrock branding system provides a fully customizable app icon and launch screen that can be configured for any type of app. All visual elements are configurable through Swift code.
### Key Features
- **Customizable gradients**: Primary and secondary colors for backgrounds
- **Configurable icons**: Use any SF Symbols for your app identity
- **Multiple pattern styles**: Dots, grid, radial glow, or no pattern
- **Layout flexibility**: Icon above title, title above icon, icon only, or title only
- **Animated launch**: Smooth fade-in animations with configurable timing
- **Icon generator**: Built-in tool to export 1024×1024 PNG for App Store
---
## What's Included
The branding system consists of these files in `Bedrock/Sources/Bedrock/Branding/`:
| File | Purpose |
|------|---------|
| `AppIconView.swift` | Renders the app icon design |
| `LaunchScreenView.swift` | Animated launch screen view |
| `AppLaunchView.swift` | Wrapper that shows launch screen before main content |
| `IconGeneratorView.swift` | Development tool to export icon images |
| `IconRenderer.swift` | Utility to render views to images |
| `BrandingPreviewView.swift` | Preview tool for icons and launch screens |
---
## Step 1: Create BrandingConfig.swift
Create a new Swift file in your app's `Shared/` folder called `BrandingConfig.swift`. This file defines your app's branding.
### Template
```swift
//
// BrandingConfig.swift
// YourApp
//
// App-specific branding configurations for icons and launch screens.
//
import SwiftUI
import Bedrock
// MARK: - App Branding Colors
extension Color {
/// Your app's branding colors for icon and launch screen.
enum Branding {
/// Primary gradient color (top/leading).
static let primary = Color(red: 0.3, green: 0.5, blue: 0.8)
/// Secondary gradient color (bottom/trailing).
static let secondary = Color(red: 0.15, green: 0.3, blue: 0.5)
/// Accent color for icons and highlights.
static let accent = Color.white
}
}
// MARK: - App Icon Configuration
extension AppIconConfig {
/// Your app's icon configuration.
static let yourApp = AppIconConfig(
title: "YOUR APP",
subtitle: nil, // Optional: text below icon
iconSymbol: "star.fill", // SF Symbol name
primaryColor: Color.Branding.primary,
secondaryColor: Color.Branding.secondary,
accentColor: Color.Branding.accent
)
}
// MARK: - Launch Screen Configuration
extension LaunchScreenConfig {
/// Your app's launch screen configuration.
static let yourApp = LaunchScreenConfig(
title: "YOUR APP",
tagline: "Your tagline here",
iconSymbols: ["star.fill"],
primaryColor: Color.Branding.primary,
secondaryColor: Color.Branding.secondary,
accentColor: Color.Branding.accent
)
}
```
---
## Step 2: Add Launch Screen to App Entry Point
Update your `@main` App struct to wrap your content with `AppLaunchView`.
### Before
```swift
@main
struct YourApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
### After
```swift
import SwiftUI
import Bedrock
@main
struct YourApp: App {
var body: some Scene {
WindowGroup {
AppLaunchView(config: .yourApp) {
ContentView()
}
}
}
}
```
**What this does:**
- Shows an animated launch screen for ~2 seconds
- Fades smoothly into your main content
- Creates a polished, professional app opening experience
---
## Step 3: Set Launch Screen Background Color
To prevent a white flash before the SwiftUI launch screen appears, you need to set the system launch screen background color to match your branding.
### 3.1 Create the Color Asset
Create a folder in your asset catalog:
```
YourApp/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json
```
With this content (update RGB values to match your `Color.Branding.primary`):
```json
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.800",
"green" : "0.500",
"red" : "0.300"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
```
### 3.2 Update Xcode Project Settings
In your `project.pbxproj`, add this line after `INFOPLIST_KEY_UILaunchScreen_Generation = YES;` in **both** Debug and Release build configurations:
```
"INFOPLIST_KEY_UILaunchScreen_BackgroundColor" = LaunchBackground;
```
Or in Xcode:
1. Select your target → Build Settings
2. Search for "Launch Screen"
3. Set "Asset Catalog Launch Image Set Name" or add User-Defined setting
**Important:** After making this change:
1. Clean build (Cmd+Shift+K)
2. Delete app from simulator/device
3. Build and run again
---
## Step 4: Add Branding Tools to Settings (Optional)
Add debug tools to your settings view for generating and previewing icons during development.
### Add to SettingsView
```swift
import SwiftUI
import Bedrock
struct SettingsView: View {
var body: some View {
NavigationStack {
List {
// ... your normal settings ...
#if DEBUG
Section("Debug") {
NavigationLink("Icon Generator") {
IconGeneratorView(config: .yourApp, appName: "YourApp")
}
NavigationLink("Branding Preview") {
BrandingPreviewView(
iconConfig: .yourApp,
launchConfig: .yourApp,
appName: "YourApp"
)
}
}
#endif
}
}
}
}
```
**Important:** Wrap in `#if DEBUG` so these tools are excluded from App Store builds.
---
## Step 5: Generate Your App Icon
### Using IconGeneratorView (Recommended)
1. **Build and run your app** in DEBUG mode
2. **Open Settings** → Debug section
3. **Tap "Icon Generator"**
4. **Tap "Generate & Save Icon"**
5. **Wait for confirmation**: "✅ Icon saved to Documents folder!"
### Retrieve the Icon
**On Simulator:**
1. Open Finder
2. Go to: `~/Library/Developer/CoreSimulator/Devices/`
3. Find your simulator device folder (sorted by date)
4. Navigate to: `data/Containers/Data/Application/[YourApp-UUID]/Documents/`
5. Copy `AppIcon.png`
**On Physical Device:**
1. Open **Files** app on your device
2. Navigate to: **On My iPhone** → **YourApp**
3. Find `AppIcon.png`
4. **AirDrop** or **share** to your Mac
**Alternative (Xcode):**
1. Go to **Window** → **Devices and Simulators**
2. Select your device/simulator
3. Find your app → Click **⚙️ gear** → **Download Container**
4. Right-click downloaded file → **Show Package Contents**
5. Navigate to `AppData/Documents/` and copy `AppIcon.png`
---
## Step 6: Add Icon to Xcode Assets
1. **Open your Xcode project**
2. **Navigate to** `Assets.xcassets``AppIcon`
3. **Drag `AppIcon.png`** into the **1024×1024** slot
4. Xcode automatically generates all required sizes
**Verify:**
1. Clean build and run
2. Check home screen for new icon
3. If unchanged, delete app and reinstall
---
## Configuration Reference
### AppIconConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `title` | `String` | Required | App name (uppercase recommended) |
| `subtitle` | `String?` | `nil` | Optional text below icon |
| `iconSymbol` | `String` | Required | SF Symbol name |
| `primaryColor` | `Color` | Blue | Top-left gradient color |
| `secondaryColor` | `Color` | Dark blue | Bottom-right gradient color |
| `accentColor` | `Color` | Light blue | Icon and text highlight color |
### LaunchScreenConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `title` | `String` | Required | App name displayed on launch |
| `subtitle` | `String?` | `nil` | Large text (like "PRO" or version) |
| `tagline` | `String?` | `nil` | Small text at bottom of screen |
| `iconSymbols` | `[String]` | `["star.fill"]` | Array of SF Symbol names |
| `cornerSymbol` | `String?` | `nil` | Symbol for corner decorations (nil = none) |
| `decorativeSymbol` | `String?` | `"circle.fill"` | Symbol in decorative line (nil = hide line) |
| `patternStyle` | `LaunchPatternStyle` | `.dots` | Background pattern style |
| `layoutStyle` | `LaunchLayoutStyle` | `.iconAboveTitle` | Content layout arrangement |
| `primaryColor` | `Color` | Blue-gray | Top gradient color |
| `secondaryColor` | `Color` | Dark blue | Bottom gradient color |
| `accentColor` | `Color` | Light blue | Icons and highlights |
| `titleColor` | `Color` | `.white` | Title text color |
| `iconSize` | `CGFloat` | `48` | Size of icon symbols |
| `titleSize` | `CGFloat` | `42` | Size of title text |
| `subtitleSize` | `CGFloat` | `72` | Size of subtitle text |
| `iconSpacing` | `CGFloat` | `8` | Spacing between icons |
| `animationDuration` | `Double` | `0.6` | Fade-in animation duration |
| `showLoadingIndicator` | `Bool` | `false` | Show spinner at bottom |
### LaunchPatternStyle
| Value | Description |
|-------|-------------|
| `.none` | Clean background, no pattern |
| `.dots` | Subtle dot pattern (default) |
| `.grid` | Grid lines pattern |
| `.radial` | Radial gradient glow from center |
### LaunchLayoutStyle
| Value | Description |
|-------|-------------|
| `.iconAboveTitle` | Icons at top, title below (default) |
| `.titleAboveIcon` | Title at top, icons below |
| `.iconOnly` | Only show icons, no text |
| `.titleOnly` | Only show text, no icons |
---
## Complete Example
Here's a full example for a camera app:
### File: `YourApp/Shared/BrandingConfig.swift`
```swift
import SwiftUI
import Bedrock
// MARK: - App Branding Colors
extension Color {
enum Branding {
// Vibrant magenta/rose gradient
static let primary = Color(red: 0.85, green: 0.25, blue: 0.45)
static let secondary = Color(red: 0.45, green: 0.12, blue: 0.35)
static let accent = Color.white
}
}
// MARK: - App Icon Configuration
extension AppIconConfig {
static let myCamera = AppIconConfig(
title: "CAMERA",
subtitle: "PRO",
iconSymbol: "camera.fill",
primaryColor: Color.Branding.primary,
secondaryColor: Color.Branding.secondary,
accentColor: Color.Branding.accent
)
}
// MARK: - Launch Screen Configuration
extension LaunchScreenConfig {
static let myCamera = LaunchScreenConfig(
title: "CAMERA PRO",
tagline: "Capture the Moment",
iconSymbols: ["camera.fill", "sparkles"],
cornerSymbol: "sparkle", // Sparkles in corners
decorativeSymbol: "circle.fill", // Circle in decorative line
patternStyle: .radial, // Radial glow effect
layoutStyle: .iconAboveTitle,
primaryColor: Color.Branding.primary,
secondaryColor: Color.Branding.secondary,
accentColor: Color.Branding.accent,
titleColor: .white,
iconSize: 52,
titleSize: 38,
iconSpacing: 12,
animationDuration: 0.6
)
}
```
### File: `YourApp/App/MyCameraApp.swift`
```swift
import SwiftUI
import Bedrock
@main
struct MyCameraApp: App {
var body: some Scene {
WindowGroup {
AppLaunchView(config: .myCamera) {
ContentView()
}
}
}
}
```
---
## Troubleshooting
### White flash before launch screen
**Cause:** iOS system launch screen doesn't match your branding colors.
**Solution:** Follow [Step 3](#step-3-set-launch-screen-background-color) to add `LaunchBackground` color to your asset catalog and configure the project build settings.
### Can't find types like `AppIconConfig`
**Solution:** Make sure you have `import Bedrock` at the top of your file.
### Launch screen doesn't appear
**Solution:**
- Verify `AppLaunchView` wraps your content in the App struct
- Check that you're using the correct config name (e.g., `.myCamera`)
- Ensure the config extension is defined in `BrandingConfig.swift`
### Icon looks different than preview
**Explanation:** iOS applies a superellipse mask to all app icons.
**Solution:** Don't add your own rounded corners—iOS does this automatically.
### "Icon saved" but can't find file
**Solution:**
1. Open **Files** app on device/simulator
2. Navigate to: **On My iPhone/iPad****[Your App Name]**
3. If folder doesn't exist, the app may need Files access
**Alternative:** Use Xcode's Devices and Simulators window to download the app container.
### Icon doesn't update in simulator
**Solution:**
1. Clean build folder: Product → Clean Build Folder (Cmd+Shift+K)
2. Delete app from simulator
3. Rebuild and run
### DEBUG section not showing in settings
**Solution:**
- Ensure you're running a DEBUG build (not Release)
- Check that code is wrapped in `#if DEBUG ... #endif`
- Verify your settings view is inside a `NavigationStack`
---
## Color Palette Ideas
### Professional Blue
```swift
primaryColor: Color(red: 0.15, green: 0.30, blue: 0.55)
secondaryColor: Color(red: 0.08, green: 0.15, blue: 0.30)
accentColor: .white
```
### Vibrant Pink/Magenta
```swift
primaryColor: Color(red: 0.85, green: 0.25, blue: 0.45)
secondaryColor: Color(red: 0.45, green: 0.12, blue: 0.35)
accentColor: .white
```
### Nature Green
```swift
primaryColor: Color(red: 0.20, green: 0.55, blue: 0.35)
secondaryColor: Color(red: 0.10, green: 0.30, blue: 0.20)
accentColor: Color(red: 0.85, green: 0.95, blue: 0.85)
```
### Warm Orange/Gold
```swift
primaryColor: Color(red: 0.95, green: 0.60, blue: 0.20)
secondaryColor: Color(red: 0.70, green: 0.35, blue: 0.10)
accentColor: .white
```
### Dark/Minimal
```swift
primaryColor: Color(red: 0.12, green: 0.12, blue: 0.15)
secondaryColor: .black
accentColor: .white
patternStyle: .none
```
---
## SF Symbol Recommendations
### Photography/Camera
- `camera.fill`, `camera.circle.fill`
- `photo.fill`, `photo.stack.fill`
- `sparkles`, `wand.and.stars`
### Social/Communication
- `message.fill`, `bubble.left.fill`
- `person.fill`, `person.2.fill`
- `heart.fill`, `star.fill`
### Productivity
- `doc.fill`, `folder.fill`
- `checkmark.circle.fill`
- `calendar`, `clock.fill`
### Music/Media
- `music.note`, `waveform`
- `play.fill`, `headphones`
- `mic.fill`, `speaker.wave.2.fill`
### Utility
- `gearshape.fill`, `wrench.fill`
- `magnifyingglass`, `location.fill`
- `bolt.fill`, `battery.100`
---
## Summary Checklist
- [ ] Create `BrandingConfig.swift` with your app's configurations
- [ ] Add `AppLaunchView` wrapper to your App entry point
- [ ] Create `LaunchBackground.colorset` in asset catalog matching primary color
- [ ] Add `INFOPLIST_KEY_UILaunchScreen_BackgroundColor` to project settings
- [ ] (Optional) Add debug section to settings with `IconGeneratorView` and `BrandingPreviewView`
- [ ] Build and run in DEBUG mode
- [ ] Generate icon using Icon Generator tool
- [ ] Retrieve icon PNG from device/simulator
- [ ] Add 1024×1024 PNG to `Assets.xcassets/AppIcon`
- [ ] Clean build and reinstall to verify icon and launch screen
---
**Happy Branding! 🎨✨**

View File

@ -0,0 +1,91 @@
//
// BrandingPreviewView.swift
// Bedrock
//
// Development view for previewing and exporting app icons and launch screens.
// Access this during development to generate icon assets.
//
import SwiftUI
/// Preview view for app branding assets.
/// Use this during development to preview icons and launch screens.
public struct BrandingPreviewView: View {
let iconConfig: AppIconConfig
let launchConfig: LaunchScreenConfig
let appName: String
// Development view: fixed sizes acceptable
private let largePreviewSize: CGFloat = 300
private let iconCornerRadiusRatio: CGFloat = 0.22
/// Creates a branding preview view.
/// - Parameters:
/// - iconConfig: The app icon configuration for this app.
/// - launchConfig: The launch screen configuration for this app.
/// - appName: The app name for display purposes.
public init(
iconConfig: AppIconConfig,
launchConfig: LaunchScreenConfig,
appName: String
) {
self.iconConfig = iconConfig
self.launchConfig = launchConfig
self.appName = appName
}
public var body: some View {
TabView {
// App Icon Preview
ScrollView {
VStack(spacing: 32) {
Text("App Icon")
.font(.largeTitle.bold())
AppIconView(config: iconConfig, size: largePreviewSize)
.clipShape(.rect(cornerRadius: largePreviewSize * iconCornerRadiusRatio))
.shadow(radius: 20)
Text("1024 × 1024px")
.font(.caption)
.foregroundStyle(.secondary)
instructionsSection
}
.padding()
}
.tabItem {
Label("Icon", systemImage: "app.fill")
}
// Launch Screen Preview
LaunchScreenView(config: launchConfig)
.tabItem {
Label("Launch", systemImage: "rectangle.portrait.fill")
}
}
}
private var instructionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("To Export")
.font(.headline)
Text("Use the Icon Generator in Settings → DEBUG to save the 1024px icon to the Files app, then add it to Xcode's Assets.xcassets/AppIcon.")
.font(.callout)
}
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.clipShape(.rect(cornerRadius: 12))
}
}
#Preview {
BrandingPreviewView(
iconConfig: .example,
launchConfig: .example,
appName: "MyApp"
)
}

View File

@ -0,0 +1,187 @@
//
// IconGeneratorView.swift
// Bedrock
//
// Development tool to generate and export app icon images.
// Run this view, tap the button, then find the icons in the Files app.
//
import SwiftUI
/// A development view that generates and saves app icon images.
/// After running, find the icons in Files app On My iPhone [App Name]
public struct IconGeneratorView: View {
let config: AppIconConfig
let appName: String
@State private var status: String = "Tap the button to generate the icon"
@State private var isGenerating = false
@State private var generatedIcon: GeneratedIconInfo?
// Development view: fixed sizes acceptable
private let previewSize: CGFloat = 200
private let iconCornerRadiusRatio: CGFloat = 0.22
/// Creates a new icon generator view.
/// - Parameters:
/// - config: The app icon configuration to use for rendering.
/// - appName: The app name for display in instructions (e.g., "SelfieCam", "MyApp").
public init(config: AppIconConfig, appName: String) {
self.config = config
self.appName = appName
}
public var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Preview
AppIconView(config: config, size: previewSize)
.clipShape(.rect(cornerRadius: previewSize * iconCornerRadiusRatio))
.shadow(radius: 10)
Text("App Icon Preview")
.font(.headline)
// Generate button
Button {
Task {
await generateIcon()
}
} label: {
HStack {
if isGenerating {
ProgressView()
.tint(.white)
}
Text(isGenerating ? "Generating..." : "Generate & Save Icon")
}
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(isGenerating ? Color.gray : Color.blue)
.clipShape(.rect(cornerRadius: 12))
}
.disabled(isGenerating)
.padding(.horizontal)
// Status
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Generated icon confirmation
if let icon = generatedIcon {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(icon.filename)
.font(.callout.monospaced())
Spacer()
Text("\(Int(icon.size))px")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.green.opacity(0.1))
.clipShape(.rect(cornerRadius: 12))
.padding(.horizontal)
}
// Instructions
instructionsSection
}
.padding(.vertical)
}
.navigationTitle("Icon Generator")
}
}
private var instructionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("After generating:")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
instructionRow(number: 1, text: "Open Files app on your device/simulator")
instructionRow(number: 2, text: "Navigate to: On My iPhone → \(appName)")
instructionRow(number: 3, text: "Find AppIcon.png (1024×1024)")
instructionRow(number: 4, text: "AirDrop or share to your Mac")
instructionRow(number: 5, text: "Drag into Xcode's Assets.xcassets/AppIcon")
}
Divider()
Text("Note: iOS uses a single 1024px icon")
.font(.subheadline.bold())
Text("Xcode automatically generates all required sizes from the 1024px source.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.gray.opacity(0.1))
.clipShape(.rect(cornerRadius: 12))
.padding(.horizontal)
}
private func instructionRow(number: Int, text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number).")
.font(.callout.bold())
.foregroundStyle(.blue)
Text(text)
.font(.callout)
}
}
@MainActor
private func generateIcon() async {
isGenerating = true
generatedIcon = nil
status = "Generating icon..."
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
// Render the 1024px icon (the only size needed for modern iOS)
let view = AppIconView(config: config, size: 1024)
let renderer = ImageRenderer(content: view)
renderer.scale = 1.0
if let uiImage = renderer.uiImage,
let data = uiImage.pngData() {
let filename = "AppIcon.png"
let fileURL = documentsPath.appending(path: filename)
do {
try data.write(to: fileURL)
generatedIcon = GeneratedIconInfo(filename: filename, size: 1024)
status = "✅ Icon saved to Documents folder!\nOpen Files app to find it."
} catch {
status = "Error saving icon: \(error.localizedDescription)"
}
} else {
status = "⚠️ Failed to render icon"
}
isGenerating = false
}
}
/// Information about a generated icon file.
public struct GeneratedIconInfo: Identifiable, Sendable {
public let id = UUID()
public let filename: String
public let size: CGFloat
public init(filename: String, size: CGFloat) {
self.filename = filename
self.size = size
}
}
#Preview {
IconGeneratorView(config: .example, appName: "MyApp")
}

View File

@ -0,0 +1,139 @@
//
// IconRenderer.swift
// Bedrock
//
// Utility to render SwiftUI views to images for app icons.
//
import SwiftUI
/// Utility to render SwiftUI views to images.
@MainActor
public struct IconRenderer {
/// Standard iOS app icon sizes.
public static let iOSIconSizes: [CGFloat] = [
1024, // App Store
180, // iPhone @3x
120, // iPhone @2x
167, // iPad Pro @2x
152, // iPad @2x
76, // iPad @1x
40, // Spotlight @2x
60, // Spotlight @3x
29, // Settings @1x
58, // Settings @2x
87 // Settings @3x
]
/// Renders an app icon view to a UIImage.
/// - Parameters:
/// - config: The app icon configuration.
/// - size: The size to render at (default 1024 for App Store).
/// - Returns: A rendered UIImage.
public static func renderAppIcon(config: AppIconConfig, size: CGFloat = 1024) -> UIImage? {
let view = AppIconView(config: config, size: size)
let renderer = ImageRenderer(content: view)
renderer.scale = 1.0
return renderer.uiImage
}
/// Renders app icons at all standard iOS sizes.
/// - Parameter config: The app icon configuration.
/// - Returns: Dictionary of size to UIImage.
public static func renderAllSizes(config: AppIconConfig) -> [CGFloat: UIImage] {
var images: [CGFloat: UIImage] = [:]
for size in iOSIconSizes {
if let image = renderAppIcon(config: config, size: size) {
images[size] = image
}
}
return images
}
/// Renders a launch screen to a UIImage.
/// - Parameters:
/// - config: The launch screen configuration.
/// - size: The size to render at.
/// - Returns: A rendered UIImage.
public static func renderLaunchScreen(config: LaunchScreenConfig, size: CGSize) -> UIImage? {
let view = StaticLaunchScreenView(config: config)
.frame(width: size.width, height: size.height)
let renderer = ImageRenderer(content: view)
renderer.scale = 1.0
return renderer.uiImage
}
}
// MARK: - Icon Export View
/// A development view for previewing and exporting app icons.
/// Add this to your app during development to easily export icons.
public struct IconExportView: View {
let config: AppIconConfig
@State private var exportedMessage: String?
public init(config: AppIconConfig) {
self.config = config
}
public var body: some View {
ScrollView {
VStack(spacing: 24) {
Text("App Icon Preview")
.font(.title.bold())
// Large preview
AppIconView(config: config, size: 256)
.clipShape(.rect(cornerRadius: 256 * 0.22))
.shadow(radius: 10)
// Size variants
Text("Size Variants")
.font(.headline)
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 100))
], spacing: 16) {
ForEach([180, 120, 87, 60, 40], id: \.self) { size in
VStack {
AppIconView(config: config, size: CGFloat(size))
.clipShape(.rect(cornerRadius: CGFloat(size) * 0.22))
Text("\(size)pt")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Export instructions
Text("Export Instructions")
.font(.headline)
.padding(.top)
VStack(alignment: .leading, spacing: 8) {
Text("1. Use Xcode's preview to screenshot these icons")
Text("2. Or use IconRenderer.renderAppIcon() in code")
Text("3. Add generated images to Assets.xcassets/AppIcon")
}
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.gray.opacity(0.1))
.clipShape(.rect(cornerRadius: 12))
if let message = exportedMessage {
Text(message)
.foregroundStyle(.green)
}
}
.padding()
}
}
}
#Preview("Icon Export") {
IconExportView(config: .example)
}

View File

@ -0,0 +1,523 @@
//
// LaunchScreenView.swift
// Bedrock
//
// A reusable launch screen design that can be customized for any app.
//
import SwiftUI
// MARK: - Pattern Style
/// The background pattern style for launch screens.
public enum LaunchPatternStyle: Sendable {
/// No pattern overlay.
case none
/// Subtle dot pattern.
case dots
/// Grid lines pattern.
case grid
/// Radial gradient overlay for depth.
case radial
}
// MARK: - Layout Style
/// The layout style for the launch screen content.
public enum LaunchLayoutStyle: Sendable {
/// Icon above title (default).
case iconAboveTitle
/// Title above icon.
case titleAboveIcon
/// Icon only, no title.
case iconOnly
/// Title only, no icon.
case titleOnly
}
// MARK: - Configuration
/// Configuration for the launch screen appearance.
public struct LaunchScreenConfig: Sendable {
// MARK: Content
public let title: String
public let subtitle: String?
public let tagline: String?
public let iconSymbols: [String]
// MARK: Decorations
public let cornerSymbol: String?
public let decorativeSymbol: String?
public let patternStyle: LaunchPatternStyle
public let layoutStyle: LaunchLayoutStyle
// MARK: Colors
public let primaryColor: Color
public let secondaryColor: Color
public let accentColor: Color
public let titleColor: Color
// MARK: Sizing
public let iconSize: CGFloat
public let titleSize: CGFloat
public let subtitleSize: CGFloat
public let iconSpacing: CGFloat
// MARK: Animation
public let animationDuration: Double
public let showLoadingIndicator: Bool
public init(
title: String,
subtitle: String? = nil,
tagline: String? = nil,
iconSymbols: [String] = ["star.fill"],
cornerSymbol: String? = nil,
decorativeSymbol: String? = "circle.fill",
patternStyle: LaunchPatternStyle = .dots,
layoutStyle: LaunchLayoutStyle = .iconAboveTitle,
primaryColor: Color = Color(red: 0.15, green: 0.20, blue: 0.30),
secondaryColor: Color = Color(red: 0.08, green: 0.10, blue: 0.18),
accentColor: Color = Color(red: 0.4, green: 0.7, blue: 1.0),
titleColor: Color = .white,
iconSize: CGFloat = 48,
titleSize: CGFloat = 42,
subtitleSize: CGFloat = 72,
iconSpacing: CGFloat = 8,
animationDuration: Double = 0.6,
showLoadingIndicator: Bool = false
) {
self.title = title
self.subtitle = subtitle
self.tagline = tagline
self.iconSymbols = iconSymbols
self.cornerSymbol = cornerSymbol
self.decorativeSymbol = decorativeSymbol
self.patternStyle = patternStyle
self.layoutStyle = layoutStyle
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
self.accentColor = accentColor
self.titleColor = titleColor
self.iconSize = iconSize
self.titleSize = titleSize
self.subtitleSize = subtitleSize
self.iconSpacing = iconSpacing
self.animationDuration = animationDuration
self.showLoadingIndicator = showLoadingIndicator
}
// MARK: - Example Configuration (for previews only)
/// Example configuration for Bedrock previews.
/// Apps should define their own configs in `BrandingConfig.swift`.
public static let example = LaunchScreenConfig(
title: "MY APP",
tagline: "Your App Tagline",
iconSymbols: ["star.fill", "sparkles"]
)
}
// MARK: - Launch Screen View
/// A customizable launch screen view for any app.
public struct LaunchScreenView: View {
let config: LaunchScreenConfig
@State private var logoScale: CGFloat = 0.8
@State private var logoOpacity: Double = 0
@State private var taglineOffset: CGFloat = 20
@State private var taglineOpacity: Double = 0
public init(config: LaunchScreenConfig) {
self.config = config
}
public var body: some View {
GeometryReader { geometry in
ZStack {
// Background gradient
backgroundGradient
// Pattern overlay
patternOverlay
// Decorative corner elements (optional)
if config.cornerSymbol != nil {
cornerDecorations(in: geometry)
}
// Main content
VStack(spacing: 0) {
Spacer()
// Logo section
logoSection
.scaleEffect(logoScale)
.opacity(logoOpacity)
Spacer()
// Bottom tagline
if let tagline = config.tagline {
Text(tagline)
.font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundStyle(config.titleColor.opacity(0.6))
.tracking(2)
.padding(.bottom, 40)
.offset(y: taglineOffset)
.opacity(taglineOpacity)
}
// Loading indicator
if config.showLoadingIndicator {
ProgressView()
.progressViewStyle(.circular)
.tint(config.accentColor)
.scaleEffect(1.2)
.padding(.bottom, 60)
}
}
}
}
.ignoresSafeArea()
.onAppear {
withAnimation(.easeOut(duration: config.animationDuration)) {
logoScale = 1.0
logoOpacity = 1.0
}
withAnimation(.easeOut(duration: config.animationDuration).delay(config.animationDuration * 0.5)) {
taglineOffset = 0
taglineOpacity = 1.0
}
}
}
// MARK: - Background
private var backgroundGradient: some View {
LinearGradient(
colors: [config.primaryColor, config.secondaryColor],
startPoint: .top,
endPoint: .bottom
)
}
@ViewBuilder
private var patternOverlay: some View {
switch config.patternStyle {
case .none:
EmptyView()
case .dots:
dotsPattern.opacity(0.03)
case .grid:
gridPattern.opacity(0.05)
case .radial:
radialOverlay.opacity(0.15)
}
}
private var dotsPattern: some View {
Canvas { context, size in
let spacing: CGFloat = 50
let dotRadius: CGFloat = 3
let rows = Int(size.height / spacing) + 1
let cols = Int(size.width / spacing) + 1
for row in 0..<rows {
for col in 0..<cols {
let offset: CGFloat = row % 2 == 0 ? 0 : spacing / 2
let x = CGFloat(col) * spacing + offset
let y = CGFloat(row) * spacing
let dot = Path(ellipseIn: CGRect(
x: x - dotRadius,
y: y - dotRadius,
width: dotRadius * 2,
height: dotRadius * 2
))
context.fill(dot, with: .color(.white))
}
}
}
}
private var gridPattern: some View {
Canvas { context, size in
let spacing: CGFloat = 40
let lineWidth: CGFloat = 0.5
// Vertical lines
var x: CGFloat = 0
while x < size.width {
let line = Path { path in
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
}
context.stroke(line, with: .color(.white), lineWidth: lineWidth)
x += spacing
}
// Horizontal lines
var y: CGFloat = 0
while y < size.height {
let line = Path { path in
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
}
context.stroke(line, with: .color(.white), lineWidth: lineWidth)
y += spacing
}
}
}
private var radialOverlay: some View {
RadialGradient(
colors: [config.accentColor.opacity(0.3), .clear],
center: .center,
startRadius: 0,
endRadius: 300
)
}
// MARK: - Corner Decorations
private func cornerDecorations(in geometry: GeometryProxy) -> some View {
ZStack {
// Top-left
cornerSymbolView
.position(x: 50, y: 80)
// Top-right
cornerSymbolView
.rotationEffect(.degrees(90))
.position(x: geometry.size.width - 50, y: 80)
// Bottom-left
cornerSymbolView
.rotationEffect(.degrees(-90))
.position(x: 50, y: geometry.size.height - 80)
// Bottom-right
cornerSymbolView
.rotationEffect(.degrees(180))
.position(x: geometry.size.width - 50, y: geometry.size.height - 80)
}
.opacity(0.15)
}
private var cornerSymbolView: some View {
Group {
if let symbol = config.cornerSymbol {
Image(systemName: symbol)
.font(.system(size: 30))
.foregroundStyle(config.accentColor)
}
}
}
// MARK: - Logo Section
@ViewBuilder
private var logoSection: some View {
switch config.layoutStyle {
case .iconAboveTitle:
VStack(spacing: 16) {
iconRow
subtitleView
titleView
decorativeLineView
}
case .titleAboveIcon:
VStack(spacing: 16) {
titleView
subtitleView
iconRow
decorativeLineView
}
case .iconOnly:
VStack(spacing: 16) {
iconRow
decorativeLineView
}
case .titleOnly:
VStack(spacing: 16) {
subtitleView
titleView
decorativeLineView
}
}
}
private var iconRow: some View {
HStack(spacing: config.iconSpacing) {
ForEach(config.iconSymbols.indices, id: \.self) { index in
Image(systemName: config.iconSymbols[index])
.font(.system(size: config.iconSize, weight: .bold))
.foregroundStyle(config.accentColor)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
}
}
}
@ViewBuilder
private var subtitleView: some View {
if let subtitle = config.subtitle {
Text(subtitle)
.font(.system(size: config.subtitleSize, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [config.accentColor, config.accentColor.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .black.opacity(0.4), radius: 4, y: 2)
}
}
private var titleView: some View {
Text(config.title)
.font(.system(size: config.titleSize, weight: .black, design: .rounded))
.tracking(6)
.foregroundStyle(
LinearGradient(
colors: [config.titleColor, config.titleColor.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .black.opacity(0.4), radius: 4, y: 2)
}
@ViewBuilder
private var decorativeLineView: some View {
if let symbol = config.decorativeSymbol {
HStack(spacing: 12) {
decorativeLine
Image(systemName: symbol)
.font(.system(size: 10))
.foregroundStyle(config.accentColor.opacity(0.6))
decorativeLine
}
.frame(width: 200)
}
}
private var decorativeLine: some View {
Rectangle()
.fill(
LinearGradient(
colors: [.clear, config.accentColor.opacity(0.4), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 1)
}
}
// MARK: - Static Launch Screen (for LaunchScreen.storyboard alternative)
/// A static version of the launch screen without animations.
/// Use this if you need to render a static image.
public struct StaticLaunchScreenView: View {
let config: LaunchScreenConfig
public init(config: LaunchScreenConfig) {
self.config = config
}
public var body: some View {
GeometryReader { geometry in
ZStack {
// Background
LinearGradient(
colors: [config.primaryColor, config.secondaryColor],
startPoint: .top,
endPoint: .bottom
)
// Logo
VStack(spacing: 16) {
if config.layoutStyle != .titleOnly {
HStack(spacing: config.iconSpacing) {
ForEach(config.iconSymbols.indices, id: \.self) { index in
Image(systemName: config.iconSymbols[index])
.font(.system(size: config.iconSize, weight: .bold))
.foregroundStyle(config.accentColor)
}
}
}
if let subtitle = config.subtitle, config.layoutStyle != .iconOnly {
Text(subtitle)
.font(.system(size: config.subtitleSize, weight: .black, design: .rounded))
.foregroundStyle(config.accentColor)
}
if config.layoutStyle != .iconOnly {
Text(config.title)
.font(.system(size: config.titleSize, weight: .black, design: .rounded))
.tracking(6)
.foregroundStyle(config.titleColor)
}
}
}
}
.ignoresSafeArea()
}
}
// MARK: - Previews
#Preview("Launch Screen - Default") {
LaunchScreenView(config: .example)
}
#Preview("Launch Screen - Minimal") {
let config = LaunchScreenConfig(
title: "CAMERA",
iconSymbols: ["camera.fill"],
patternStyle: .none,
primaryColor: Color(red: 0.1, green: 0.1, blue: 0.15),
secondaryColor: .black,
accentColor: .white
)
return LaunchScreenView(config: config)
}
#Preview("Launch Screen - Radial") {
let config = LaunchScreenConfig(
title: "SELFIE CAM",
tagline: "Look Your Best",
iconSymbols: ["camera.fill", "sparkles"],
cornerSymbol: "sparkle",
patternStyle: .radial,
primaryColor: Color(red: 0.85, green: 0.25, blue: 0.45),
secondaryColor: Color(red: 0.45, green: 0.12, blue: 0.35),
accentColor: .white
)
return LaunchScreenView(config: config)
}
#Preview("Launch Screen - Grid") {
let config = LaunchScreenConfig(
title: "NOTES",
iconSymbols: ["note.text"],
patternStyle: .grid,
layoutStyle: .iconAboveTitle,
primaryColor: Color(red: 0.95, green: 0.85, blue: 0.5),
secondaryColor: Color(red: 0.9, green: 0.75, blue: 0.3),
accentColor: Color(red: 0.3, green: 0.2, blue: 0.1),
titleColor: Color(red: 0.3, green: 0.2, blue: 0.1)
)
return LaunchScreenView(config: config)
}
#Preview("Static Launch") {
StaticLaunchScreenView(config: .example)
}

View File

@ -2,13 +2,10 @@
// Exports.swift
// Bedrock
//
// Re-exports for convenient importing.
// Common imports for Bedrock.
//
// Note: We import but don't re-export SwiftUI/Foundation to avoid
// ambiguity issues with macros like #Preview when apps also import these.
import SwiftUI
// Re-export SwiftUI for consumers who only import Bedrock
@_exported import SwiftUI
// Re-export Foundation for common types
@_exported import Foundation
import Foundation

View File

@ -0,0 +1,108 @@
//
// PremiumGate.swift
// Bedrock
//
// Utility for premium-gating settings values in freemium apps.
// Provides a consistent pattern for handling premium vs free user access.
//
import Foundation
/// Utility enum for premium-gating settings values.
///
/// Use this to create consistent premium/free behavior across your app's settings.
/// Values are preserved in storage but defaults are returned for non-premium users,
/// so users don't lose their settings if they unsubscribe and later re-subscribe.
///
/// ## Usage
///
/// For boolean settings that are entirely premium:
/// ```swift
/// var isSkinSmoothingEnabled: Bool {
/// get { PremiumGate.get(cloudSync.data.isSkinSmoothingEnabled, default: false, isPremium: isPremiumUnlocked) }
/// set { guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return }
/// updateSettings { $0.isSkinSmoothingEnabled = newValue }
/// }
/// }
/// ```
///
/// For settings where only some values are premium:
/// ```swift
/// var selectedTimer: TimerOption {
/// get {
/// let stored = TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .three
/// return PremiumGate.get(stored, default: .three, premiumValues: [.five, .ten], isPremium: isPremiumUnlocked)
/// }
/// set {
/// guard PremiumGate.canSet(newValue, premiumValues: [.five, .ten], isPremium: isPremiumUnlocked) else { return }
/// updateSettings { $0.selectedTimerRaw = newValue.rawValue }
/// }
/// }
/// ```
public enum PremiumGate {
// MARK: - Getters
/// Returns the stored value if premium, otherwise returns the default.
///
/// Use for settings that are entirely premium-only (e.g., skin smoothing, mirror flip).
/// - Parameters:
/// - stored: The value stored in settings
/// - defaultValue: The value to return for non-premium users
/// - isPremium: Whether the user has premium access
/// - Returns: The stored value if premium, otherwise the default
public static func get<T>(
_ stored: T,
default defaultValue: T,
isPremium: Bool
) -> T {
isPremium ? stored : defaultValue
}
/// Returns the stored value if premium or if it's not a premium value, otherwise returns the default.
///
/// Use for settings where only some values are premium (e.g., timer options, quality levels).
/// - Parameters:
/// - stored: The value stored in settings
/// - defaultValue: The value to return for non-premium users when they have a premium value stored
/// - premiumValues: The set of values that require premium access
/// - isPremium: Whether the user has premium access
/// - Returns: The stored value if premium or not a premium value, otherwise the default
public static func get<T: Hashable>(
_ stored: T,
default defaultValue: T,
premiumValues: Set<T>,
isPremium: Bool
) -> T {
if !isPremium && premiumValues.contains(stored) {
return defaultValue
}
return stored
}
// MARK: - Setters
/// Checks if setting any value should be allowed (for entirely premium settings).
///
/// - Parameter isPremium: Whether the user has premium access
/// - Returns: True if the user can modify this setting
public static func canSet(isPremium: Bool) -> Bool {
isPremium
}
/// Checks if setting a specific value should be allowed.
///
/// Use for settings where only some values are premium.
/// - Parameters:
/// - value: The value the user wants to set
/// - premiumValues: The set of values that require premium access
/// - isPremium: Whether the user has premium access
/// - Returns: True if the user can set this specific value
public static func canSet<T: Hashable>(
_ value: T,
premiumValues: Set<T>,
isPremium: Bool
) -> Bool {
isPremium || !premiumValues.contains(value)
}
}

View File

@ -1 +1,94 @@
{"sourceLanguage":"en","strings":{},"version":"1.0"}
{
"sourceLanguage" : "en",
"strings" : {
"%lld." : {
"comment" : "A numbered list item with a callout number and accompanying text. The first argument is the number of the item. The second argument is the text describing the item.",
"isCommentAutoGenerated" : true
},
"%lld%%" : {
"comment" : "A text label showing the current volume percentage. The argument is the volume as a percentage (0.0 to 1.0).",
"isCommentAutoGenerated" : true
},
"%lldpt" : {
"comment" : "A caption below an app icon that shows its size in points. The argument is the size of the icon in points.",
"isCommentAutoGenerated" : true
},
"%lldpx" : {
"comment" : "A label displaying the size of the generated app icon. The argument is the size of the icon in pixels.",
"isCommentAutoGenerated" : true
},
"1. Use Xcode's preview to screenshot these icons" : {
"comment" : "An instruction in the icon export view.",
"isCommentAutoGenerated" : true
},
"2. Or use IconRenderer.renderAppIcon() in code" : {
"comment" : "An instruction within the icon export view, explaining how to generate icons using code.",
"isCommentAutoGenerated" : true
},
"3. Add generated images to Assets.xcassets/AppIcon" : {
"comment" : "Instructions for adding generated app icon images to Xcode's asset catalog.",
"isCommentAutoGenerated" : true
},
"1024 × 1024px" : {
"comment" : "A description of the size of the app icon.",
"isCommentAutoGenerated" : true
},
"After generating:" : {
"comment" : "A heading for the instructions section of the IconGeneratorView.",
"isCommentAutoGenerated" : true
},
"App Icon" : {
"comment" : "A heading for the app icon preview section.",
"isCommentAutoGenerated" : true
},
"App Icon Preview" : {
"comment" : "A heading describing the preview of the app icon.",
"isCommentAutoGenerated" : true
},
"Export Instructions" : {
"comment" : "A section header describing how to export app icons.",
"isCommentAutoGenerated" : true
},
"Generate & Save Icon" : {
"comment" : "A button label that triggers icon generation and saving.",
"isCommentAutoGenerated" : true
},
"Generating..." : {
"comment" : "A label indicating that an icon is being generated.",
"isCommentAutoGenerated" : true
},
"Icon" : {
"comment" : "A tab label for the \"Icon\" tab in the branding preview view.",
"isCommentAutoGenerated" : true
},
"Icon Generator" : {
"comment" : "The title of the icon generator view.",
"isCommentAutoGenerated" : true
},
"Launch" : {
"comment" : "A tab label for the launch screen preview.",
"isCommentAutoGenerated" : true
},
"Note: iOS uses a single 1024px icon" : {
"comment" : "A note explaining that iOS uses a single 1024px icon.",
"isCommentAutoGenerated" : true
},
"Size Variants" : {
"comment" : "A heading for the size variants of an app icon.",
"isCommentAutoGenerated" : true
},
"To Export" : {
"comment" : "A section header explaining how to export branding assets.",
"isCommentAutoGenerated" : true
},
"Use the Icon Generator in Settings → DEBUG to save the 1024px icon to the Files app, then add it to Xcode's Assets.xcassets/AppIcon." : {
"comment" : "Instructions for exporting an app icon.",
"isCommentAutoGenerated" : true
},
"Xcode automatically generates all required sizes from the 1024px source." : {
"comment" : "A footnote explaining that Xcode automatically creates all necessary icon sizes from the original 1024px image.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
}

View File

@ -153,6 +153,15 @@ public final class CloudSyncManager<T: PersistableData> {
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
}
// Set initial sync status
if !iCloudAvailable {
syncStatus = "iCloud unavailable"
} else if !iCloudEnabled {
syncStatus = "Sync disabled"
} else {
syncStatus = "Ready"
}
// Register for iCloud changes
if iCloudAvailable, let store = iCloudStore {
NotificationCenter.default.addObserver(
@ -173,6 +182,8 @@ public final class CloudSyncManager<T: PersistableData> {
// Trigger iCloud sync
if iCloudEnabled {
store.synchronize()
lastSyncDate = Date()
syncStatus = "Synced"
}
}
@ -182,6 +193,9 @@ public final class CloudSyncManager<T: PersistableData> {
// On fresh install, wait for iCloud data
if data.syncPriority == 0 && iCloudAvailable && iCloudEnabled {
scheduleDelayedCloudCheck()
} else {
// Existing user with data - initial sync is complete
hasCompletedInitialSync = true
}
}
@ -337,11 +351,15 @@ public final class CloudSyncManager<T: PersistableData> {
private func scheduleDelayedCloudCheck() {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Scheduling delayed cloud check...")
isSyncing = true
syncStatus = "Syncing..."
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
guard let store = iCloudStore else {
isSyncing = false
syncStatus = "iCloud unavailable"
hasCompletedInitialSync = true
return
}
@ -364,6 +382,9 @@ public final class CloudSyncManager<T: PersistableData> {
)
}
isSyncing = false
lastSyncDate = Date()
syncStatus = "Synced"
hasCompletedInitialSync = true
}
}
@ -375,7 +396,6 @@ public final class CloudSyncManager<T: PersistableData> {
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
@ -392,6 +412,9 @@ public final class CloudSyncManager<T: PersistableData> {
)
}
lastSyncDate = Date()
syncStatus = "Synced"
case NSUbiquitousKeyValueStoreQuotaViolationChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full"

View File

@ -113,18 +113,23 @@ public enum DefaultTheme: AppColorTheme {
/// 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`).
///
/// Note: `TextColors` and `ButtonColors` are used instead of `Text` and `Button`
/// to avoid conflicts with SwiftUI's `Text` and `Button` views.
public extension Color {
/// Default surface colors.
typealias Surface = DefaultSurfaceColors
/// Default text colors.
typealias Text = DefaultTextColors
/// Note: Named `TextColors` to avoid conflict with SwiftUI's `Text` view.
typealias TextColors = DefaultTextColors
/// Default accent colors.
typealias Accent = DefaultAccentColors
/// Default button colors.
typealias Button = DefaultButtonColors
/// Note: Named `ButtonColors` to avoid conflict with SwiftUI's `Button` view.
typealias ButtonColors = DefaultButtonColors
/// Default status colors.
typealias Status = DefaultStatusColors

View File

@ -25,7 +25,7 @@ public enum Design {
// MARK: - Debug
/// Set to true to enable debug logging in Bedrock.
public static var showDebugLogs = false
nonisolated(unsafe) public static var showDebugLogs = true
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
public static func debugLog(_ message: String) {

View File

@ -9,7 +9,7 @@ import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
@_implementationOnly import UIKit
#endif
/// Device information utilities for responsive layouts and adaptive UI.

View File

@ -74,7 +74,7 @@ public extension View {
.pulsing(isActive: true)
Text("Pulsing highlights interactive areas")
.foregroundStyle(Color.Text.secondary)
.foregroundStyle(Color.TextColors.secondary)
.font(.caption)
}
}

View File

@ -0,0 +1,61 @@
//
// BadgePill.swift
// Bedrock
//
// A capsule-shaped badge for displaying short text values.
//
import SwiftUI
/// 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: - Preview
#Preview {
HStack(spacing: Design.Spacing.medium) {
BadgePill(text: "$9.99", isSelected: false)
BadgePill(text: "$9.99", isSelected: true)
BadgePill(text: "PRO", isSelected: false, accentColor: .orange)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,391 @@
# Settings View Setup Guide
This guide explains how to create a branded settings screen using Bedrock's theming system and settings components.
## Overview
Bedrock provides:
1. **Color protocols** for consistent theming (`SurfaceColorProvider`, `AccentColorProvider`, etc.)
2. **Reusable settings components** (`SettingsToggle`, `SettingsCard`, `SegmentedPicker`, etc.)
3. **Design constants** for spacing, typography, and animations
By creating a custom theme, your app gets a unique visual identity while reusing all the settings UI components.
---
## Step 1: Create Your App's Theme
Create a file called `[AppName]Theme.swift` in your app's `Shared/` folder.
### Define Surface Colors
Surface colors create visual depth and separation. Use a subtle tint that matches your brand:
```swift
import SwiftUI
import Bedrock
/// Surface colors with a subtle [brand]-tint.
public enum MyAppSurfaceColors: SurfaceColorProvider {
/// Primary background - darkest level
public static let primary = Color(red: 0.08, green: 0.06, blue: 0.10)
/// Secondary/elevated surface
public static let secondary = Color(red: 0.12, green: 0.08, blue: 0.14)
/// Tertiary/card surface - most elevated
public static let tertiary = Color(red: 0.16, green: 0.11, blue: 0.18)
/// Overlay background (for sheets/modals)
public static let overlay = Color(red: 0.10, green: 0.07, blue: 0.12)
/// Card/grouped element background
public static let card = Color(red: 0.14, green: 0.10, blue: 0.16)
/// Subtle fill for grouped content sections
public static let groupedFill = Color(red: 0.12, green: 0.09, blue: 0.14)
/// Section fill for list sections
public static let sectionFill = Color(red: 0.16, green: 0.12, blue: 0.18)
}
```
### Define Accent Colors
These are your brand's primary interactive colors:
```swift
public enum MyAppAccentColors: AccentColorProvider {
/// Primary accent - your main brand color
public static let primary = Color(red: 0.85, green: 0.25, blue: 0.45)
/// Light variant
public static let light = Color(red: 0.95, green: 0.45, blue: 0.60)
/// Dark variant
public static let dark = Color(red: 0.65, green: 0.18, blue: 0.35)
/// Secondary accent for contrast
public static let secondary = Color(red: 1.0, green: 0.95, blue: 0.90)
}
```
### Define Other Color Providers
Complete the theme with text, button, status, border, and interactive colors:
```swift
public enum MyAppTextColors: 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
}
public enum MyAppButtonColors: ButtonColorProvider {
public static let primaryLight = Color(red: 0.95, green: 0.40, blue: 0.55)
public static let primaryDark = Color(red: 0.75, green: 0.20, blue: 0.40)
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)
}
public enum MyAppStatusColors: 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.5, green: 0.7, blue: 0.95)
}
public enum MyAppBorderColors: 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 = MyAppAccentColors.primary.opacity(Design.Opacity.medium)
}
public enum MyAppInteractiveColors: InteractiveColorProvider {
public static let selected = MyAppAccentColors.primary.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 = MyAppAccentColors.light
}
```
### Combine Into a Theme
```swift
public enum MyAppTheme: AppColorTheme {
public typealias Surface = MyAppSurfaceColors
public typealias Text = MyAppTextColors
public typealias Accent = MyAppAccentColors
public typealias Button = MyAppButtonColors
public typealias Status = MyAppStatusColors
public typealias Border = MyAppBorderColors
public typealias Interactive = MyAppInteractiveColors
}
```
### Add Convenience Typealiases
Create top-level typealiases with an `App` prefix to avoid conflicts with Bedrock's defaults:
```swift
/// Short typealiases for cleaner usage throughout the app.
/// These avoid conflicts with Bedrock's default typealiases by using unique names.
///
/// Usage:
/// ```swift
/// .background(AppSurface.primary)
/// .foregroundStyle(AppAccent.primary)
/// ```
typealias AppSurface = MyAppSurfaceColors
typealias AppTextColors = MyAppTextColors
typealias AppAccent = MyAppAccentColors
typealias AppButtonColors = MyAppButtonColors
typealias AppStatus = MyAppStatusColors
typealias AppBorder = MyAppBorderColors
typealias AppInteractive = MyAppInteractiveColors
```
> **Important**: Do NOT add typealiases inside `extension Color { }` as they will conflict with Bedrock's defaults and cause "ambiguous use" compiler errors. Use top-level `App`-prefixed typealiases instead.
---
## Step 2: Create a Settings Card Container
Add a reusable card container for grouping related settings:
```swift
/// A card container that provides visual grouping for settings sections.
struct SettingsCard<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
content
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(AppSurface.card)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
}
}
```
---
## Step 3: Build Your Settings View
Use Bedrock's components with your theme colors:
```swift
import SwiftUI
import Bedrock
struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.medium) {
// Section with header and card
SettingsSectionHeader(
title: "Appearance",
systemImage: "paintbrush",
accentColor: AppAccent.primary
)
SettingsCard {
SettingsToggle(
title: "Dark Mode",
subtitle: "Use dark appearance",
isOn: $viewModel.darkMode,
accentColor: AppAccent.primary
)
SegmentedPicker(
title: "Theme",
options: [("Light", "light"), ("Dark", "dark"), ("System", "system")],
selection: $viewModel.theme,
accentColor: AppAccent.primary
)
}
// Another section
SettingsSectionHeader(
title: "Notifications",
systemImage: "bell",
accentColor: AppAccent.primary
)
SettingsCard {
SettingsToggle(
title: "Push Notifications",
subtitle: "Receive alerts and updates",
isOn: $viewModel.pushEnabled,
accentColor: AppAccent.primary
)
}
Spacer(minLength: Design.Spacing.xxxLarge)
}
.padding(.horizontal, Design.Spacing.large)
}
.background(AppSurface.primary)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.foregroundStyle(AppAccent.primary)
}
}
}
}
}
```
---
## Available Components
### SettingsSectionHeader
A section header with optional icon and accent color.
```swift
SettingsSectionHeader(
title: "Account",
systemImage: "person.circle",
accentColor: Color.Accent.primary
)
```
### SettingsToggle
A toggle row with title and subtitle.
```swift
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: $viewModel.soundEnabled,
accentColor: Color.Accent.primary
)
```
### SegmentedPicker
A horizontal capsule-style picker.
```swift
SegmentedPicker(
title: "Quality",
options: [("Low", 0), ("Medium", 1), ("High", 2)],
selection: $viewModel.quality,
accentColor: Color.Accent.primary
)
```
### SelectableRow
A card-like row for option selection.
```swift
SelectableRow(
title: "Premium",
subtitle: "Unlock all features",
isSelected: plan == .premium,
accentColor: Color.Accent.primary,
badge: { BadgePill(text: "$9.99") }
) {
plan = .premium
}
```
### VolumePicker
A slider with speaker icons for volume/percentage values.
```swift
VolumePicker(
label: "Volume",
volume: $viewModel.volume,
accentColor: Color.Accent.primary
)
```
### SettingsRow
A navigation-style row with icon and chevron.
```swift
SettingsRow(
systemImage: "star.fill",
title: "Rate App",
iconColor: Color.Status.warning
) {
openAppStore()
}
```
### BadgePill
A capsule badge for values or tags.
```swift
BadgePill(
text: "$4.99",
isSelected: isCurrentPlan,
accentColor: Color.Accent.primary
)
```
---
## Color Relationship Guide
| Surface Level | Use Case | Visual Depth |
|---------------|----------|--------------|
| `AppSurface.primary` | Main background | Darkest |
| `AppSurface.overlay` | Sheet/modal backgrounds | Slightly elevated |
| `AppSurface.card` | Settings cards | Distinct from background |
| `AppSurface.sectionFill` | Section containers | Most elevated |
| Accent Purpose | Color |
|----------------|-------|
| Interactive elements | `AppAccent.primary` |
| Highlights | `AppAccent.light` |
| Pressed states | `AppAccent.dark` |
| Pro/Premium sections | `AppStatus.warning` |
| Debug/Error sections | `AppStatus.error` |
---
## Tips
1. **Derive surface colors from your brand**: Add a subtle RGB tint (e.g., if brand is pink, surfaces should have a rose undertone).
2. **Use consistent accent colors**: Pass `accentColor: AppAccent.primary` to all Bedrock components.
3. **Group related settings**: Wrap related toggles/pickers in a `SettingsCard` for visual hierarchy.
4. **Section-specific accents**: Use `AppStatus.warning` for premium sections, `AppStatus.error` for debug.
5. **Test with Dynamic Type**: Bedrock uses `Design.BaseFontSize` values that scale properly.
6. **Avoid `Color.` typealiases**: Use `App`-prefixed typealiases to prevent conflicts with Bedrock's defaults.
---
## Example Apps
- **SelfieCam**: Rose/magenta theme with camera-focused settings
- **SelfieRingLight**: Similar structure with ring light controls
- **CameraTester**: Neutral theme for testing/debugging
See each app's `[AppName]Theme.swift` for implementation examples.

View File

@ -0,0 +1,100 @@
//
// SegmentedPicker.swift
// Bedrock
//
// A horizontal segmented picker with capsule-style buttons.
//
import SwiftUI
/// 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: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SegmentedPicker(
title: "Animation Speed",
options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")],
selection: .constant("normal")
)
SegmentedPicker(
title: "Theme",
options: [("Light", 0), ("Dark", 1)],
selection: .constant(1)
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,152 @@
//
// SelectableRow.swift
// Bedrock
//
// A card-like selectable row with title, subtitle, optional badge, and selection indicator.
//
import SwiftUI
/// 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)
}
}
// MARK: - Convenience Initializer
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: - Preview
#Preview {
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: {}
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,59 @@
//
// SelectionIndicator.swift
// Bedrock
//
// A circle indicator that shows selected (checkmark) or unselected (outline) state.
//
import SwiftUI
/// 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: - Preview
#Preview {
HStack(spacing: Design.Spacing.large) {
SelectionIndicator(isSelected: true)
SelectionIndicator(isSelected: false)
SelectionIndicator(isSelected: true, accentColor: .green)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -1,611 +0,0 @@
//
// 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)
}

View File

@ -0,0 +1,134 @@
//
// SettingsRow.swift
// Bedrock
//
// A simple settings row with icon, title, and optional value/accessory.
//
import SwiftUI
/// 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)
}
}
// MARK: - Convenience Initializer
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 {
VStack(spacing: Design.Spacing.small) {
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: {}
)
SettingsRow(
systemImage: "bell.fill",
title: "Notifications",
iconColor: .Status.error,
action: {}
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,64 @@
//
// SettingsSectionHeader.swift
// Bedrock
//
// A section header for settings screens.
//
import SwiftUI
/// 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?
/// The accent color for the header.
public let accentColor: Color
/// Creates a section header.
/// - Parameters:
/// - title: The section title.
/// - systemImage: Optional SF Symbol name.
/// - accentColor: The accent color (default: primary accent).
public init(title: String, systemImage: String? = nil, accentColor: Color = .Accent.primary) {
self.title = title
self.systemImage = systemImage
self.accentColor = accentColor
}
public var body: some View {
HStack(spacing: Design.Spacing.small) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(accentColor.opacity(Design.Opacity.strong))
}
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: - Preview
#Preview {
VStack(alignment: .leading, spacing: 0) {
SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush")
SettingsSectionHeader(title: "Preferences", systemImage: "gearshape")
SettingsSectionHeader(title: "Premium", systemImage: "crown", accentColor: .orange)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,208 @@
//
// SettingsSlider.swift
// Bedrock
//
// A slider setting with title, description, and value display.
//
import SwiftUI
/// A slider setting with title, description, and value display.
///
/// Use this for numeric settings with a slider control, following the pattern:
/// - Title with current value on the right
/// - Description underneath the title
/// - Slider with optional icons underneath the description
///
/// ```swift
/// SettingsSlider(
/// title: "Ring Size",
/// subtitle: "Adjusts the size of the light ring",
/// value: $settings.ringSize,
/// in: 20...100,
/// step: 5,
/// format: { "\($0)pt" },
/// leadingIcon: Image(systemName: "circle"),
/// trailingIcon: Image(systemName: "circle").font(.title)
/// )
/// ```
public struct SettingsSlider<Value: BinaryFloatingPoint & Sendable>: View where Value.Stride: BinaryFloatingPoint {
/// The main title text.
public let title: String
/// The subtitle/description text.
public let subtitle: String
/// Binding to the slider value.
@Binding public var value: Value
/// The range of the slider.
public let range: ClosedRange<Value>
/// The step increment for the slider.
public let step: Value.Stride
/// A closure that formats the value for display.
public let format: (Value) -> String
/// The accent color for the slider.
public let accentColor: Color
/// Optional leading icon for the slider.
public let leadingIcon: Image?
/// Optional trailing icon for the slider.
public let trailingIcon: Image?
/// Creates a settings slider.
/// - Parameters:
/// - title: The main title.
/// - subtitle: The subtitle description.
/// - value: Binding to slider value.
/// - range: The range of values (default: 0...1).
/// - step: The step increment (default: 0.1).
/// - format: Closure to format the value display (default: percentage).
/// - accentColor: The accent color (default: primary accent).
/// - leadingIcon: Optional icon on the left side of slider.
/// - trailingIcon: Optional icon on the right side of slider.
public init(
title: String,
subtitle: String,
value: Binding<Value>,
in range: ClosedRange<Value> = 0...1,
step: Value.Stride = 0.1,
format: @escaping (Value) -> String = { "\($0)" },
accentColor: Color = .Accent.primary,
leadingIcon: Image? = nil,
trailingIcon: Image? = nil
) {
self.title = title
self.subtitle = subtitle
self._value = value
self.range = range
self.step = step
self.format = format
self.accentColor = accentColor
self.leadingIcon = leadingIcon
self.trailingIcon = trailingIcon
}
public var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(title)
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text(format(value))
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(subtitle)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.medium) {
if let leadingIcon = leadingIcon {
leadingIcon
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Slider(value: $value, in: range, step: step)
.tint(accentColor)
if let trailingIcon = trailingIcon {
trailingIcon
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
// MARK: - Convenience Initializers
/// Creates a percentage slider (0-100%).
public func SettingsSliderPercentage(
title: String,
subtitle: String,
value: Binding<Float>,
in range: ClosedRange<Float> = 0...1,
step: Float.Stride = 0.05,
accentColor: Color = .Accent.primary,
leadingIcon: Image? = nil,
trailingIcon: Image? = nil
) -> SettingsSlider<Float> {
SettingsSlider(
title: title,
subtitle: subtitle,
value: value,
in: range,
step: step,
format: { "\(Int($0 * 100))%" },
accentColor: accentColor,
leadingIcon: leadingIcon,
trailingIcon: trailingIcon
)
}
/// Creates an integer slider with custom unit.
public func SettingsSliderInteger(
title: String,
subtitle: String,
value: Binding<Int>,
in range: ClosedRange<Int>,
step: Int.Stride = 1,
unit: String,
accentColor: Color = .Accent.primary,
leadingIcon: Image? = nil,
trailingIcon: Image? = nil
) -> SettingsSlider<Float> {
SettingsSlider(
title: title,
subtitle: subtitle,
value: Binding(
get: { Float(value.wrappedValue) },
set: { value.wrappedValue = Int($0) }
),
in: Float(range.lowerBound)...Float(range.upperBound),
step: Float(step),
format: { "\(Int($0))\(unit)" },
accentColor: accentColor,
leadingIcon: leadingIcon,
trailingIcon: trailingIcon
)
}
// MARK: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SettingsSlider(
title: "Ring Size",
subtitle: "Adjusts the size of the light ring around the camera preview",
value: .constant(40.0),
in: 20...100,
step: 5,
format: { "\($0)pt" },
leadingIcon: Image(systemName: "circle"),
trailingIcon: Image(systemName: "circle")
)
SettingsSliderPercentage(
title: "Brightness",
subtitle: "Adjusts the brightness of the ring light",
value: .constant(0.75),
leadingIcon: Image(systemName: "sun.min"),
trailingIcon: Image(systemName: "sun.max.fill")
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,87 @@
//
// SettingsToggle.swift
// Bedrock
//
// A toggle setting row with title and subtitle.
//
import SwiftUI
/// 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: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
SettingsToggle(
title: "Sound Effects",
subtitle: "Play sounds for events",
isOn: .constant(true)
)
SettingsToggle(
title: "Notifications",
subtitle: "Receive push notifications",
isOn: .constant(false)
)
}
.padding()
.background(Color.Surface.overlay)
}

View File

@ -0,0 +1,78 @@
//
// VolumePicker.swift
// Bedrock
//
// A volume slider with speaker icons.
//
import SwiftUI
/// 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: - Preview
#Preview {
VStack(spacing: Design.Spacing.medium) {
VolumePicker(volume: .constant(0.8))
VolumePicker(label: "Music", volume: .constant(0.5))
}
.padding()
.background(Color.Surface.overlay)
}