Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-02 08:37:08 -06:00
parent 01931244e0
commit a4eaa187e5
8 changed files with 567 additions and 54 deletions

23
PRD.md
View File

@ -462,12 +462,17 @@ TheNoiseClock/
│ │ │ ├── SoundSelectionView.swift │ │ │ ├── SoundSelectionView.swift
│ │ │ ├── TimePickerSection.swift │ │ │ ├── TimePickerSection.swift
│ │ │ └── TimeUntilAlarmSection.swift │ │ │ └── TimeUntilAlarmSection.swift
│ │ └── Noise/ │ │ ├── Noise/
│ │ │ └── Views/
│ │ │ ├── NoiseView.swift
│ │ │ └── Components/
│ │ │ ├── SoundCategoryView.swift
│ │ │ └── SoundControlView.swift
│ │ └── Onboarding/
│ │ └── Views/ │ │ └── Views/
│ │ ├── NoiseView.swift │ │ ├── OnboardingView.swift
│ │ └── Components/ │ │ └── Components/
│ │ ├── SoundCategoryView.swift │ │ └── OnboardingPageView.swift
│ │ └── SoundControlView.swift
│ └── Resources/ │ └── Resources/
│ ├── LaunchScreen.storyboard # Branded native launch screen │ ├── LaunchScreen.storyboard # Branded native launch screen
│ ├── sounds.json # Ambient sound configuration and definitions │ ├── sounds.json # Ambient sound configuration and definitions
@ -535,6 +540,16 @@ The following changes **automatically require** PRD updates:
- **Version consistency**: Code and documentation must always be in sync - **Version consistency**: Code and documentation must always be in sync
- **No manual requests**: Users should not need to ask for PRD updates - **No manual requests**: Users should not need to ask for PRD updates
### 7. Onboarding Experience
- **First-launch detection**: Uses OnboardingState to detect first-time users
- **Welcome screen**: Introduces the app with branded visuals
- **Feature highlights**: Dedicated pages for Clock, Alarms, and Noise features
- **Permission request**: Notification permission request with contextual explanation
- **Skip option**: Users can skip onboarding at any time
- **Persistent state**: Onboarding completion saved to UserDefaults
- **Smooth transitions**: Animated page transitions with page indicators
- **Get Started flow**: Clear call-to-action to complete onboarding
## Key User Interactions ## Key User Interactions
### Clock Tab ### Clock Tab

View File

@ -48,6 +48,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Optional wake-lock to keep the screen on - Optional wake-lock to keep the screen on
### What's New ### What's New
- First-launch onboarding with feature highlights and notification setup
- Branded launch experience with Bedrock theming - Branded launch experience with Bedrock theming
- Redesigned settings interface with cards, toggles, and sliders - Redesigned settings interface with cards, toggles, and sliders
- Centralized build identifiers via xcconfig - Centralized build identifiers via xcconfig
@ -62,6 +63,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Rich alarm editor with scheduling and snooze controls - Rich alarm editor with scheduling and snooze controls
- Bedrock-based theming and branded launch - Bedrock-based theming and branded launch
- iPhone and iPad support with adaptive layouts - iPhone and iPad support with adaptive layouts
- First-launch onboarding with feature highlights and permission setup
--- ---

View File

@ -11,7 +11,8 @@ import Bedrock
/// Main tab navigation coordinator /// Main tab navigation coordinator
struct ContentView: View { struct ContentView: View {
// MARK: - Body // MARK: - Properties
private enum Tab: Hashable { private enum Tab: Hashable {
case clock case clock
case alarms case alarms
@ -21,50 +22,71 @@ struct ContentView: View {
@State private var selectedTab: Tab = .clock @State private var selectedTab: Tab = .clock
@State private var clockViewModel = ClockViewModel() @State private var clockViewModel = ClockViewModel()
@State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock")
// MARK: - Body
var body: some View { var body: some View {
TabView(selection: $selectedTab) { ZStack {
NavigationStack { // Main tab content
ClockView(viewModel: clockViewModel) TabView(selection: $selectedTab) {
} NavigationStack {
.tabItem { ClockView(viewModel: clockViewModel)
Label("Clock", systemImage: "clock") }
} .tabItem {
.tag(Tab.clock) Label("Clock", systemImage: "clock")
}
NavigationStack { .tag(Tab.clock)
AlarmView()
} NavigationStack {
.tabItem { AlarmView()
Label("Alarms", systemImage: "alarm") }
} .tabItem {
.tag(Tab.alarms) Label("Alarms", systemImage: "alarm")
}
NavigationStack { .tag(Tab.alarms)
NoiseView()
} NavigationStack {
.tabItem { NoiseView()
Label("Noise", systemImage: "waveform") }
} .tabItem {
.tag(Tab.noise) Label("Noise", systemImage: "waveform")
}
.tag(Tab.noise)
NavigationStack { NavigationStack {
ClockSettingsView(style: clockViewModel.style) { newStyle in ClockSettingsView(
clockViewModel.updateStyle(newStyle) style: clockViewModel.style,
onCommit: { newStyle in
clockViewModel.updateStyle(newStyle)
},
onResetOnboarding: {
onboardingState.reset()
}
)
}
.tabItem {
Label("Settings", systemImage: "gearshape")
}
.tag(Tab.settings)
}
.onChange(of: selectedTab) { oldValue, newValue in
if oldValue == .clock && newValue != .clock {
clockViewModel.setDisplayMode(false)
} }
} }
.tabItem { .accentColor(AppAccent.primary)
Label("Settings", systemImage: "gearshape") .background(Color.Branding.primary.ignoresSafeArea())
}
.tag(Tab.settings) // Onboarding overlay for first-time users
} if !onboardingState.hasCompletedWelcome {
.onChange(of: selectedTab) { oldValue, newValue in OnboardingView {
if oldValue == .clock && newValue != .clock { onboardingState.completeWelcome()
clockViewModel.setDisplayMode(false) }
.transition(.opacity)
} }
} }
.accentColor(AppAccent.primary) .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
.background(Color.Branding.primary.ignoresSafeArea())
} }
} }

View File

@ -14,15 +14,21 @@ struct ClockSettingsView: View {
// MARK: - Properties // MARK: - Properties
@State private var style: ClockStyle @State private var style: ClockStyle
let onCommit: (ClockStyle) -> Void let onCommit: (ClockStyle) -> Void
var onResetOnboarding: (() -> Void)?
@State private var digitColor: Color = .white @State private var digitColor: Color = .white
@State private var backgroundColor: Color = .black @State private var backgroundColor: Color = .black
@State private var showAdvancedSettings = false @State private var showAdvancedSettings = false
// MARK: - Init // MARK: - Init
init(style: ClockStyle, onCommit: @escaping (ClockStyle) -> Void) { init(
style: ClockStyle,
onCommit: @escaping (ClockStyle) -> Void,
onResetOnboarding: (() -> Void)? = nil
) {
self._style = State(initialValue: style) self._style = State(initialValue: style)
self.onCommit = onCommit self.onCommit = onCommit
self.onResetOnboarding = onResetOnboarding
} }
// MARK: - Body // MARK: - Body
@ -92,6 +98,32 @@ struct ClockSettingsView: View {
appName: "TheNoiseClock" appName: "TheNoiseClock"
) )
} }
if let onResetOnboarding {
Divider()
.background(AppBorder.subtle)
Button {
onResetOnboarding()
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Reset Onboarding")
.typography(.body)
.foregroundStyle(AppTextColors.primary)
Text("Show onboarding screens again on next launch")
.typography(.caption)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
Image(systemName: "arrow.counterclockwise")
.foregroundStyle(AppAccent.primary)
}
.padding(Design.Spacing.medium)
.background(AppSurface.primary)
}
.buttonStyle(.plain)
}
} }
#endif #endif
} }

View File

@ -22,7 +22,7 @@ struct ClockOverlayContainer: View {
showBattery: style.showBattery, showBattery: style.showBattery,
showDate: style.showDate, showDate: style.showDate,
color: style.effectiveDigitColor, color: style.effectiveDigitColor,
opacity: style.overlayOpacity, opacity: style.clockOpacity,
dateFormat: style.dateFormat dateFormat: style.dateFormat
) )
.padding(.top, Design.Spacing.small) .padding(.top, Design.Spacing.small)

View File

@ -22,16 +22,6 @@ struct OverlaySection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsSlider(
title: "Overlay Opacity",
subtitle: "Adjust battery and date visibility",
value: $style.overlayOpacity,
in: 0.0...1.0,
step: 0.01,
format: SliderFormat.percentage,
accentColor: AppAccent.primary
)
SettingsToggle( SettingsToggle(
title: "Battery Level", title: "Battery Level",
subtitle: "Show battery percentage", subtitle: "Show battery percentage",

View File

@ -0,0 +1,62 @@
//
// OnboardingPageView.swift
// TheNoiseClock
//
// Reusable onboarding page component with icon, title, and description.
//
import SwiftUI
import Bedrock
/// A single onboarding page with icon, title, and description
struct OnboardingPageView: View {
// MARK: - Properties
let icon: String
let iconColor: Color
let title: String
let description: String
// MARK: - Body
var body: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Icon
SymbolIcon(icon, size: .hero, color: iconColor, weight: .medium)
.padding(.bottom, Design.Spacing.medium)
// Title
Text(title)
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
// Description
Text(description)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Preview
#Preview {
OnboardingPageView(
icon: "clock.fill",
iconColor: AppAccent.primary,
title: "Beautiful Clock Display",
description: "A stunning full-screen digital clock with customizable fonts, colors, and animations."
)
.background(AppSurface.primary)
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,390 @@
//
// OnboardingView.swift
// TheNoiseClock
//
// Streamlined onboarding flow optimized for time-to-value.
// Shows real clock immediately, requests permissions with context,
// and gets users to their "aha moment" fast.
//
import SwiftUI
import Bedrock
/// Streamlined onboarding optimized for activation
struct OnboardingView: View {
// MARK: - Properties
let onComplete: () -> Void
@State private var currentPage = 0
@State private var notificationPermissionGranted = false
@State private var showCelebration = false
private let totalPages = 3
// MARK: - Body
var body: some View {
ZStack {
// Background
AppSurface.primary
.ignoresSafeArea()
VStack(spacing: 0) {
// Page content
TabView(selection: $currentPage) {
welcomeWithClockPage
.tag(0)
permissionsPage
.tag(1)
getStartedPage
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage)
// Bottom controls
bottomControls
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.xxLarge)
}
// Celebration overlay
if showCelebration {
celebrationOverlay
}
}
}
// MARK: - Page 1: Welcome with Live Clock Preview
private var welcomeWithClockPage: some View {
VStack(spacing: Design.Spacing.large) {
Spacer()
// Live clock preview - immediate value using TimelineView
liveClockPreview
.padding(.bottom, Design.Spacing.medium)
Text("The Noise Clock")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Your beautiful bedside companion")
.typography(.title3)
.foregroundStyle(AppTextColors.secondary)
// Quick feature highlights - benefit focused
VStack(spacing: Design.Spacing.medium) {
featureHighlight(
icon: "moon.stars.fill",
text: "Fall asleep to soothing sounds"
)
featureHighlight(
icon: "alarm.fill",
text: "Wake up gently, on your terms"
)
featureHighlight(
icon: "hand.tap.fill",
text: "Long-press for immersive mode"
)
}
.padding(.top, Design.Spacing.large)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var liveClockPreview: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
OnboardingClockText(date: context.date)
}
}
private func featureHighlight(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundStyle(AppAccent.primary)
.frame(width: 28)
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
Spacer()
}
.padding(.horizontal, Design.Spacing.xxLarge)
}
// MARK: - Page 2: Permissions (Contextual)
private var permissionsPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Alarm icon with context
ZStack {
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "alarm.fill")
.font(.system(size: 50, weight: .medium))
.foregroundStyle(AppAccent.primary)
}
Text("Never miss an alarm")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Enable notifications so alarms work even when the app is closed or your phone is locked.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Permission button or success state
permissionButton
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var permissionButton: some View {
Group {
if notificationPermissionGranted {
// Success state
HStack(spacing: Design.Spacing.small) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
Text("You're all set!")
}
.foregroundStyle(AppStatus.success)
.typography(.bodyEmphasis)
.padding(Design.Spacing.medium)
.background(AppStatus.success.opacity(0.15))
.cornerRadius(Design.CornerRadius.medium)
} else {
// Request button
Button {
requestNotificationPermission()
} label: {
HStack {
Image(systemName: "bell.badge.fill")
Text("Enable Notifications")
}
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
}
}
}
// MARK: - Page 3: Get Started (Quick Win)
private var getStartedPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Celebration icon
ZStack {
Circle()
.fill(AppStatus.success.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60, weight: .medium))
.foregroundStyle(AppStatus.success)
}
Text("You're ready!")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
Text("Tap below to start using your new bedside clock. Try long-pressing the clock for immersive mode!")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Quick tips
VStack(alignment: .leading, spacing: Design.Spacing.small) {
tipRow(icon: "hand.tap", text: "Long-press clock for full screen")
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
tipRow(icon: "plus", text: "Tap + on Alarms to add one")
}
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func tipRow(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundStyle(AppAccent.primary)
.frame(width: 24)
Text(text)
.typography(.callout)
.foregroundStyle(AppTextColors.secondary)
}
.padding(.horizontal, Design.Spacing.xxLarge)
}
// MARK: - Bottom Controls
private var bottomControls: some View {
VStack(spacing: Design.Spacing.large) {
// Page indicators
HStack(spacing: Design.Spacing.small) {
ForEach(0..<totalPages, id: \.self) { index in
Capsule()
.fill(index == currentPage ? AppAccent.primary : AppTextColors.tertiary)
.frame(width: index == currentPage ? 24 : 8, height: 8)
.animation(.easeInOut(duration: 0.2), value: currentPage)
}
}
// Navigation buttons
HStack(spacing: Design.Spacing.large) {
// Back / Skip button
Button {
if currentPage > 0 {
withAnimation {
currentPage -= 1
}
} else {
onComplete()
}
} label: {
Text(currentPage == 0 ? "Skip" : "Back")
.typography(.bodyEmphasis)
.foregroundStyle(AppTextColors.secondary)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
}
// Next / Get Started button
Button {
if currentPage < totalPages - 1 {
withAnimation {
currentPage += 1
}
} else {
triggerCelebration()
}
} label: {
Text(currentPage == totalPages - 1 ? "Get Started" : "Next")
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
}
}
}
// MARK: - Celebration
private var celebrationOverlay: some View {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: Design.Spacing.large) {
Image(systemName: "party.popper.fill")
.font(.system(size: 60))
.foregroundStyle(AppAccent.primary)
Text("Let's go!")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
}
.padding(Design.Spacing.xxxLarge)
.background(AppSurface.overlay)
.cornerRadius(Design.CornerRadius.xxLarge)
.shadow(radius: 20)
}
.transition(.opacity.combined(with: .scale))
}
// MARK: - Actions
private func requestNotificationPermission() {
Task {
let granted = await NotificationUtils.requestPermissions()
withAnimation(.spring(duration: 0.3)) {
notificationPermissionGranted = granted
}
// Auto-advance after permission granted
if granted {
try? await Task.sleep(for: .milliseconds(800))
withAnimation {
currentPage = 2
}
}
}
}
private func triggerCelebration() {
withAnimation(.spring(duration: 0.4)) {
showCelebration = true
}
// Dismiss after short celebration
Task {
try? await Task.sleep(for: .milliseconds(1200))
onComplete()
}
}
}
// MARK: - Onboarding Clock Text
/// Separate view for TimelineView content to avoid view builder issues
private struct OnboardingClockText: View {
let date: Date
private var timeString: String {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm"
return formatter.string(from: date)
}
var body: some View {
Text(timeString)
.font(.system(size: 80, weight: .bold, design: .rounded))
.foregroundStyle(AppAccent.primary)
.contentTransition(.numericText())
.animation(.snappy(duration: 0.3), value: timeString)
.shadow(color: AppAccent.primary.opacity(0.5), radius: 20)
}
}
// MARK: - Preview
#Preview {
OnboardingView {
print("Onboarding complete")
}
.preferredColorScheme(.dark)
}