diff --git a/README.md b/README.md index c6d576c..fe39692 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/Bedrock/Audio/SoundManager.swift b/Sources/Bedrock/Audio/SoundManager.swift index d6a58df..fb17fd4 100644 --- a/Sources/Bedrock/Audio/SoundManager.swift +++ b/Sources/Bedrock/Audio/SoundManager.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI #if canImport(UIKit) -import UIKit +@_implementationOnly import UIKit #endif // MARK: - Sound Protocol diff --git a/Sources/Bedrock/Branding/AppIconView.swift b/Sources/Bedrock/Branding/AppIconView.swift new file mode 100644 index 0000000..400ce09 --- /dev/null +++ b/Sources/Bedrock/Branding/AppIconView.swift @@ -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..: 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) + } +} diff --git a/Sources/Bedrock/Branding/BRANDING_GUIDE.md b/Sources/Bedrock/Branding/BRANDING_GUIDE.md new file mode 100644 index 0000000..335dfcb --- /dev/null +++ b/Sources/Bedrock/Branding/BRANDING_GUIDE.md @@ -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! 🎨✨** diff --git a/Sources/Bedrock/Branding/BrandingPreviewView.swift b/Sources/Bedrock/Branding/BrandingPreviewView.swift new file mode 100644 index 0000000..35f3672 --- /dev/null +++ b/Sources/Bedrock/Branding/BrandingPreviewView.swift @@ -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" + ) +} diff --git a/Sources/Bedrock/Branding/IconGeneratorView.swift b/Sources/Bedrock/Branding/IconGeneratorView.swift new file mode 100644 index 0000000..90d8358 --- /dev/null +++ b/Sources/Bedrock/Branding/IconGeneratorView.swift @@ -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") +} diff --git a/Sources/Bedrock/Branding/IconRenderer.swift b/Sources/Bedrock/Branding/IconRenderer.swift new file mode 100644 index 0000000..8f42da5 --- /dev/null +++ b/Sources/Bedrock/Branding/IconRenderer.swift @@ -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) +} + diff --git a/Sources/Bedrock/Branding/LaunchScreenView.swift b/Sources/Bedrock/Branding/LaunchScreenView.swift new file mode 100644 index 0000000..758bfc8 --- /dev/null +++ b/Sources/Bedrock/Branding/LaunchScreenView.swift @@ -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.. 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) +} diff --git a/Sources/Bedrock/Exports.swift b/Sources/Bedrock/Exports.swift index 0ba7d59..ded610c 100644 --- a/Sources/Bedrock/Exports.swift +++ b/Sources/Bedrock/Exports.swift @@ -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 diff --git a/Sources/Bedrock/Premium/PremiumGate.swift b/Sources/Bedrock/Premium/PremiumGate.swift new file mode 100644 index 0000000..ce0e62e --- /dev/null +++ b/Sources/Bedrock/Premium/PremiumGate.swift @@ -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( + _ 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( + _ stored: T, + default defaultValue: T, + premiumValues: Set, + 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( + _ value: T, + premiumValues: Set, + isPremium: Bool + ) -> Bool { + isPremium || !premiumValues.contains(value) + } +} diff --git a/Sources/Bedrock/Resources/Localizable.xcstrings b/Sources/Bedrock/Resources/Localizable.xcstrings index 3d7b7a9..21ffb43 100644 --- a/Sources/Bedrock/Resources/Localizable.xcstrings +++ b/Sources/Bedrock/Resources/Localizable.xcstrings @@ -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" +} \ No newline at end of file diff --git a/Sources/Bedrock/Storage/CloudSyncManager.swift b/Sources/Bedrock/Storage/CloudSyncManager.swift index 43be448..c87d94e 100644 --- a/Sources/Bedrock/Storage/CloudSyncManager.swift +++ b/Sources/Bedrock/Storage/CloudSyncManager.swift @@ -153,6 +153,15 @@ public final class CloudSyncManager { 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 { // Trigger iCloud sync if iCloudEnabled { store.synchronize() + lastSyncDate = Date() + syncStatus = "Synced" } } @@ -182,6 +193,9 @@ public final class CloudSyncManager { // 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 { 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 { ) } + isSyncing = false + lastSyncDate = Date() + syncStatus = "Synced" hasCompletedInitialSync = true } } @@ -375,7 +396,6 @@ public final class CloudSyncManager { 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 { ) } + lastSyncDate = Date() + syncStatus = "Synced" + case NSUbiquitousKeyValueStoreQuotaViolationChange: Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded") syncStatus = "Storage full" diff --git a/Sources/Bedrock/Theme/Colors.swift b/Sources/Bedrock/Theme/Colors.swift index ad46475..3df1d00 100644 --- a/Sources/Bedrock/Theme/Colors.swift +++ b/Sources/Bedrock/Theme/Colors.swift @@ -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 diff --git a/Sources/Bedrock/Theme/Design.swift b/Sources/Bedrock/Theme/Design.swift index c39aa01..98c7169 100644 --- a/Sources/Bedrock/Theme/Design.swift +++ b/Sources/Bedrock/Theme/Design.swift @@ -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) { diff --git a/Sources/Bedrock/Utilities/DeviceInfo.swift b/Sources/Bedrock/Utilities/DeviceInfo.swift index e33bf5c..8271544 100644 --- a/Sources/Bedrock/Utilities/DeviceInfo.swift +++ b/Sources/Bedrock/Utilities/DeviceInfo.swift @@ -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. diff --git a/Sources/Bedrock/Views/Effects/PulsingModifier.swift b/Sources/Bedrock/Views/Effects/PulsingModifier.swift index fde8feb..871b3bc 100644 --- a/Sources/Bedrock/Views/Effects/PulsingModifier.swift +++ b/Sources/Bedrock/Views/Effects/PulsingModifier.swift @@ -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) } } diff --git a/Sources/Bedrock/Views/Settings/BadgePill.swift b/Sources/Bedrock/Views/Settings/BadgePill.swift new file mode 100644 index 0000000..4a61764 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/BadgePill.swift @@ -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) +} diff --git a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md new file mode 100644 index 0000000..aee238c --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md @@ -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: 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. diff --git a/Sources/Bedrock/Views/Settings/SegmentedPicker.swift b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift new file mode 100644 index 0000000..8af0826 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SegmentedPicker.swift @@ -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: View { + /// The title/label for the picker. + public let title: String + + /// The available options as (label, value) pairs. + public let options: [(String, T)] + + /// Binding to the selected value. + @Binding public var selection: T + + /// The accent color for the selected button. + public let accentColor: Color + + /// Creates a segmented picker. + /// - Parameters: + /// - title: The title label. + /// - options: Array of (label, value) tuples. + /// - selection: Binding to selected value. + /// - accentColor: The accent color (default: primary accent). + public init( + title: String, + options: [(String, T)], + selection: Binding, + accentColor: Color = .Accent.primary + ) { + self.title = title + self.options = options + self._selection = selection + self.accentColor = accentColor + } + + public var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text(title) + .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white) + + HStack(spacing: Design.Spacing.small) { + ForEach(options.indices, id: \.self) { index in + let option = options[index] + Button { + selection = option.1 + } label: { + Text(option.0) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong)) + .padding(.vertical, Design.Spacing.small) + .frame(maxWidth: .infinity) + .background( + Capsule() + .fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle)) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.vertical, Design.Spacing.xSmall) + } +} + +// MARK: - 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) +} diff --git a/Sources/Bedrock/Views/Settings/SelectableRow.swift b/Sources/Bedrock/Views/Settings/SelectableRow.swift new file mode 100644 index 0000000..b2e2c97 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SelectableRow.swift @@ -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: 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) +} diff --git a/Sources/Bedrock/Views/Settings/SelectionIndicator.swift b/Sources/Bedrock/Views/Settings/SelectionIndicator.swift new file mode 100644 index 0000000..3e4042d --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SelectionIndicator.swift @@ -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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsComponents.swift b/Sources/Bedrock/Views/Settings/SettingsComponents.swift deleted file mode 100644 index 60192e1..0000000 --- a/Sources/Bedrock/Views/Settings/SettingsComponents.swift +++ /dev/null @@ -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, - accentColor: Color = .Accent.primary - ) { - self.title = title - self.subtitle = subtitle - self._isOn = isOn - self.accentColor = accentColor - } - - public var body: some View { - Toggle(isOn: $isOn) { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(title) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) - .foregroundStyle(.white) - - Text(subtitle) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - } - .tint(accentColor) - .padding(.vertical, Design.Spacing.xSmall) - } -} - -// MARK: - Volume Picker - -/// A volume slider with speaker icons. -/// -/// Use this for audio volume or similar 0-1 range settings. -public struct VolumePicker: View { - /// The label for the picker. - public let label: String - - /// Binding to the volume level (0.0 to 1.0). - @Binding public var volume: Float - - /// The accent color for the slider. - public let accentColor: Color - - /// Creates a volume picker. - /// - Parameters: - /// - label: The label text (default: "Volume"). - /// - volume: Binding to volume (0.0-1.0). - /// - accentColor: The accent color (default: primary accent). - public init( - label: String = "Volume", - volume: Binding, - accentColor: Color = .Accent.primary - ) { - self.label = label - self._volume = volume - self.accentColor = accentColor - } - - public var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - HStack { - Text(label) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) - .foregroundStyle(.white) - - Spacer() - - Text("\(Int(volume * 100))%") - .font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - HStack(spacing: Design.Spacing.medium) { - Image(systemName: "speaker.fill") - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - - Slider(value: $volume, in: 0...1, step: 0.1) - .tint(accentColor) - - Image(systemName: "speaker.wave.3.fill") - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - } - .padding(.vertical, Design.Spacing.xSmall) - } -} - -// MARK: - Segmented Picker - -/// A horizontal segmented picker with capsule-style buttons. -/// -/// Use this for selecting from a small number of options (2-4). -/// -/// ```swift -/// SegmentedPicker( -/// title: "Theme", -/// options: [("Light", "light"), ("Dark", "dark"), ("System", "system")], -/// selection: $theme -/// ) -/// ``` -public struct SegmentedPicker: View { - /// The title/label for the picker. - public let title: String - - /// The available options as (label, value) pairs. - public let options: [(String, T)] - - /// Binding to the selected value. - @Binding public var selection: T - - /// The accent color for the selected button. - public let accentColor: Color - - /// Creates a segmented picker. - /// - Parameters: - /// - title: The title label. - /// - options: Array of (label, value) tuples. - /// - selection: Binding to selected value. - /// - accentColor: The accent color (default: primary accent). - public init( - title: String, - options: [(String, T)], - selection: Binding, - accentColor: Color = .Accent.primary - ) { - self.title = title - self.options = options - self._selection = selection - self.accentColor = accentColor - } - - public var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - Text(title) - .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) - .foregroundStyle(.white) - - HStack(spacing: Design.Spacing.small) { - ForEach(options.indices, id: \.self) { index in - let option = options[index] - Button { - selection = option.1 - } label: { - Text(option.0) - .font(.system(size: Design.BaseFontSize.body, weight: .medium)) - .foregroundStyle(selection == option.1 ? .black : .white.opacity(Design.Opacity.strong)) - .padding(.vertical, Design.Spacing.small) - .frame(maxWidth: .infinity) - .background( - Capsule() - .fill(selection == option.1 ? accentColor : Color.white.opacity(Design.Opacity.subtle)) - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.vertical, Design.Spacing.xSmall) - } -} - -// MARK: - Selectable Row - -/// A card-like selectable row with title, subtitle, optional badge, and selection indicator. -/// -/// Use this for settings pickers, option lists, or any selectable item. -/// -/// ```swift -/// SelectableRow( -/// title: "Premium", -/// subtitle: "Unlock all features", -/// isSelected: plan == .premium -/// ) { -/// plan = .premium -/// } -/// ``` -public struct SelectableRow: View { - /// The main title text. - public let title: String - - /// The subtitle/description text. - public let subtitle: String - - /// Whether this row is currently selected. - public let isSelected: Bool - - /// Optional badge view. - public let badge: Badge? - - /// The accent color for selection highlighting. - public let accentColor: Color - - /// Action when tapped. - public let action: () -> Void - - /// Creates a selectable row. - /// - Parameters: - /// - title: The main title. - /// - subtitle: The subtitle description. - /// - isSelected: Whether this row is selected. - /// - accentColor: Color for selection (default: primary accent). - /// - badge: Optional badge view. - /// - action: Action when tapped. - public init( - title: String, - subtitle: String, - isSelected: Bool, - accentColor: Color = .Accent.primary, - @ViewBuilder badge: () -> Badge? = { nil as EmptyView? }, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.isSelected = isSelected - self.accentColor = accentColor - self.badge = badge() - self.action = action - } - - public var body: some View { - Button(action: action) { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(title) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(subtitle) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer() - - if let badge = badge { - badge - } - - SelectionIndicator(isSelected: isSelected, accentColor: accentColor) - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(isSelected ? accentColor.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - isSelected ? accentColor.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) - } -} - -// Convenience initializer for rows without a badge -extension SelectableRow where Badge == EmptyView { - /// Creates a selectable row without a badge. - public init( - title: String, - subtitle: String, - isSelected: Bool, - accentColor: Color = .Accent.primary, - action: @escaping () -> Void - ) { - self.title = title - self.subtitle = subtitle - self.isSelected = isSelected - self.accentColor = accentColor - self.badge = nil - self.action = action - } -} - -// MARK: - Selection Indicator - -/// A circle indicator that shows selected (checkmark) or unselected (outline) state. -public struct SelectionIndicator: View { - /// Whether the item is selected. - public let isSelected: Bool - - /// The accent color for the checkmark. - public let accentColor: Color - - /// The size of the indicator. - public let size: CGFloat - - /// Creates a selection indicator. - /// - Parameters: - /// - isSelected: Whether selected. - /// - accentColor: Color for checkmark (default: primary accent). - /// - size: Size of the indicator (default: checkmark size from design). - public init( - isSelected: Bool, - accentColor: Color = .Accent.primary, - size: CGFloat = Design.Size.checkmark - ) { - self.isSelected = isSelected - self.accentColor = accentColor - self.size = size - } - - public var body: some View { - if isSelected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: size)) - .foregroundStyle(accentColor) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: size, height: size) - } - } -} - -// MARK: - Badge Pill - -/// A capsule-shaped badge for displaying short text values. -/// -/// Use this to highlight values, tags, or status indicators. -public struct BadgePill: View { - /// The text to display in the badge. - public let text: String - - /// Whether the parent row is selected. - public let isSelected: Bool - - /// The accent color. - public let accentColor: Color - - /// Creates a badge pill. - /// - Parameters: - /// - text: The badge text. - /// - isSelected: Whether the parent row is selected. - /// - accentColor: Color for the badge (default: primary accent). - public init( - text: String, - isSelected: Bool = false, - accentColor: Color = .Accent.primary - ) { - self.text = text - self.isSelected = isSelected - self.accentColor = accentColor - } - - public var body: some View { - Text(text) - .font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded)) - .foregroundStyle(isSelected ? .black : accentColor) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background( - Capsule() - .fill(isSelected ? accentColor : accentColor.opacity(Design.Opacity.hint)) - ) - } -} - -// MARK: - Settings Section Header - -/// A section header for settings screens. -public struct SettingsSectionHeader: View { - /// The section title. - public let title: String - - /// Optional system image name. - public let systemImage: String? - - /// Creates a section header. - /// - Parameters: - /// - title: The section title. - /// - systemImage: Optional SF Symbol name. - public init(title: String, systemImage: String? = nil) { - self.title = title - self.systemImage = systemImage - } - - public var body: some View { - HStack(spacing: Design.Spacing.small) { - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: Design.BaseFontSize.medium)) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - } - - Text(title) - .font(.system(size: Design.BaseFontSize.caption, weight: .semibold)) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - .textCase(.uppercase) - .tracking(0.5) - - Spacer() - } - .padding(.horizontal, Design.Spacing.xSmall) - .padding(.top, Design.Spacing.large) - .padding(.bottom, Design.Spacing.xSmall) - } -} - -// MARK: - Settings Row - -/// A simple settings row with icon, title, and optional value/accessory. -public struct SettingsRow: View { - /// The row icon (SF Symbol name). - public let systemImage: String - - /// The row title. - public let title: String - - /// Optional value text. - public let value: String? - - /// The icon background color. - public let iconColor: Color - - /// Optional accessory view. - public let accessory: Accessory? - - /// Action when tapped. - public let action: () -> Void - - /// Creates a settings row. - public init( - systemImage: String, - title: String, - value: String? = nil, - iconColor: Color = .Accent.primary, - @ViewBuilder accessory: () -> Accessory? = { nil as EmptyView? }, - action: @escaping () -> Void - ) { - self.systemImage = systemImage - self.title = title - self.value = value - self.iconColor = iconColor - self.accessory = accessory() - self.action = action - } - - public var body: some View { - Button(action: action) { - HStack(spacing: Design.Spacing.medium) { - Image(systemName: systemImage) - .font(.system(size: Design.BaseFontSize.medium)) - .foregroundStyle(.white) - .frame(width: Design.Size.iconContainerSmall, height: Design.Size.iconContainerSmall) - .background(iconColor.opacity(Design.Opacity.heavy)) - .clipShape(.rect(cornerRadius: Design.CornerRadius.xSmall)) - - Text(title) - .font(.system(size: Design.BaseFontSize.medium)) - .foregroundStyle(.white) - - Spacer() - - if let value { - Text(value) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - if let accessory { - accessory - } else { - Image(systemName: "chevron.right") - .font(.system(size: Design.BaseFontSize.body, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.light)) - } - } - .padding(.vertical, Design.Spacing.medium) - .padding(.horizontal, Design.Spacing.medium) - .background(Color.Surface.card) - .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) - } - .buttonStyle(.plain) - } -} - -// Convenience initializer for rows without accessory -extension SettingsRow where Accessory == EmptyView { - /// Creates a settings row without an accessory. - public init( - systemImage: String, - title: String, - value: String? = nil, - iconColor: Color = .Accent.primary, - action: @escaping () -> Void - ) { - self.systemImage = systemImage - self.title = title - self.value = value - self.iconColor = iconColor - self.accessory = nil - self.action = action - } -} - -// MARK: - Preview - -#Preview { - ScrollView { - VStack(spacing: Design.Spacing.large) { - SettingsSectionHeader(title: "Appearance", systemImage: "paintbrush") - - // Selectable rows - VStack(spacing: Design.Spacing.small) { - SelectableRow( - title: "Light", - subtitle: "Always use light mode", - isSelected: true, - action: {} - ) - - SelectableRow( - title: "Dark", - subtitle: "Always use dark mode", - isSelected: false, - action: {} - ) - - SelectableRow( - title: "Premium", - subtitle: "Unlock all features", - isSelected: false, - badge: { BadgePill(text: "$9.99", isSelected: false) }, - action: {} - ) - } - - SettingsSectionHeader(title: "Preferences", systemImage: "gearshape") - - SettingsToggle( - title: "Sound Effects", - subtitle: "Play sounds for events", - isOn: .constant(true) - ) - - SegmentedPicker( - title: "Animation Speed", - options: [("Fast", "fast"), ("Normal", "normal"), ("Slow", "slow")], - selection: .constant("normal") - ) - - VolumePicker(volume: .constant(0.8)) - - SettingsSectionHeader(title: "About", systemImage: "info.circle") - - SettingsRow( - systemImage: "star.fill", - title: "Rate App", - iconColor: .Status.warning, - action: {} - ) - - SettingsRow( - systemImage: "envelope.fill", - title: "Contact Us", - value: "support@example.com", - iconColor: .Status.info, - action: {} - ) - } - .padding() - } - .background(Color.Surface.overlay) -} diff --git a/Sources/Bedrock/Views/Settings/SettingsRow.swift b/Sources/Bedrock/Views/Settings/SettingsRow.swift new file mode 100644 index 0000000..29e678e --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsRow.swift @@ -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: 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift b/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift new file mode 100644 index 0000000..e449962 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsSectionHeader.swift @@ -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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsSlider.swift b/Sources/Bedrock/Views/Settings/SettingsSlider.swift new file mode 100644 index 0000000..ad67d9f --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsSlider.swift @@ -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: 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 + + /// 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, + in range: ClosedRange = 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, + in range: ClosedRange = 0...1, + step: Float.Stride = 0.05, + accentColor: Color = .Accent.primary, + leadingIcon: Image? = nil, + trailingIcon: Image? = nil +) -> SettingsSlider { + 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, + in range: ClosedRange, + step: Int.Stride = 1, + unit: String, + accentColor: Color = .Accent.primary, + leadingIcon: Image? = nil, + trailingIcon: Image? = nil +) -> SettingsSlider { + 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) +} diff --git a/Sources/Bedrock/Views/Settings/SettingsToggle.swift b/Sources/Bedrock/Views/Settings/SettingsToggle.swift new file mode 100644 index 0000000..0662565 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/SettingsToggle.swift @@ -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, + 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) +} diff --git a/Sources/Bedrock/Views/Settings/VolumePicker.swift b/Sources/Bedrock/Views/Settings/VolumePicker.swift new file mode 100644 index 0000000..fcd735f --- /dev/null +++ b/Sources/Bedrock/Views/Settings/VolumePicker.swift @@ -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, + 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) +}