From a4eaa187e5dfb257d2041d089990f001f0726319 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 2 Feb 2026 08:37:08 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 23 +- README.md | 2 + TheNoiseClock/App/ContentView.swift | 98 +++-- .../Clock/Views/ClockSettingsView.swift | 34 +- .../Components/ClockOverlayContainer.swift | 2 +- .../Components/Settings/OverlaySection.swift | 10 - .../Views/Components/OnboardingPageView.swift | 62 +++ .../Onboarding/Views/OnboardingView.swift | 390 ++++++++++++++++++ 8 files changed, 567 insertions(+), 54 deletions(-) create mode 100644 TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPageView.swift create mode 100644 TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift diff --git a/PRD.md b/PRD.md index 9980ab6..85bf27e 100644 --- a/PRD.md +++ b/PRD.md @@ -462,12 +462,17 @@ TheNoiseClock/ │ │ │ ├── SoundSelectionView.swift │ │ │ ├── TimePickerSection.swift │ │ │ └── TimeUntilAlarmSection.swift -│ │ └── Noise/ +│ │ ├── Noise/ +│ │ │ └── Views/ +│ │ │ ├── NoiseView.swift +│ │ │ └── Components/ +│ │ │ ├── SoundCategoryView.swift +│ │ │ └── SoundControlView.swift +│ │ └── Onboarding/ │ │ └── Views/ -│ │ ├── NoiseView.swift +│ │ ├── OnboardingView.swift │ │ └── Components/ -│ │ ├── SoundCategoryView.swift -│ │ └── SoundControlView.swift +│ │ └── OnboardingPageView.swift │ └── Resources/ │ ├── LaunchScreen.storyboard # Branded native launch screen │ ├── 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 - **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 ### Clock Tab diff --git a/README.md b/README.md index b0d1995..3853853 100644 --- a/README.md +++ b/README.md @@ -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 ### What's New +- First-launch onboarding with feature highlights and notification setup - Branded launch experience with Bedrock theming - Redesigned settings interface with cards, toggles, and sliders - 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 - Bedrock-based theming and branded launch - iPhone and iPad support with adaptive layouts +- First-launch onboarding with feature highlights and permission setup --- diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index 775297e..8f4543c 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -11,7 +11,8 @@ import Bedrock /// Main tab navigation coordinator struct ContentView: View { - // MARK: - Body + // MARK: - Properties + private enum Tab: Hashable { case clock case alarms @@ -21,50 +22,71 @@ struct ContentView: View { @State private var selectedTab: Tab = .clock @State private var clockViewModel = ClockViewModel() + @State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock") + + // MARK: - Body var body: some View { - TabView(selection: $selectedTab) { - NavigationStack { - ClockView(viewModel: clockViewModel) - } - .tabItem { - Label("Clock", systemImage: "clock") - } - .tag(Tab.clock) - - NavigationStack { - AlarmView() - } - .tabItem { - Label("Alarms", systemImage: "alarm") - } - .tag(Tab.alarms) - - NavigationStack { - NoiseView() - } - .tabItem { - Label("Noise", systemImage: "waveform") - } - .tag(Tab.noise) + ZStack { + // Main tab content + TabView(selection: $selectedTab) { + NavigationStack { + ClockView(viewModel: clockViewModel) + } + .tabItem { + Label("Clock", systemImage: "clock") + } + .tag(Tab.clock) + + NavigationStack { + AlarmView() + } + .tabItem { + Label("Alarms", systemImage: "alarm") + } + .tag(Tab.alarms) + + NavigationStack { + NoiseView() + } + .tabItem { + Label("Noise", systemImage: "waveform") + } + .tag(Tab.noise) - NavigationStack { - ClockSettingsView(style: clockViewModel.style) { newStyle in - clockViewModel.updateStyle(newStyle) + NavigationStack { + ClockSettingsView( + 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 { - Label("Settings", systemImage: "gearshape") - } - .tag(Tab.settings) - } - .onChange(of: selectedTab) { oldValue, newValue in - if oldValue == .clock && newValue != .clock { - clockViewModel.setDisplayMode(false) + .accentColor(AppAccent.primary) + .background(Color.Branding.primary.ignoresSafeArea()) + + // Onboarding overlay for first-time users + if !onboardingState.hasCompletedWelcome { + OnboardingView { + onboardingState.completeWelcome() + } + .transition(.opacity) } } - .accentColor(AppAccent.primary) - .background(Color.Branding.primary.ignoresSafeArea()) + .animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome) } } diff --git a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift index fb3480d..3f7821e 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockSettingsView.swift @@ -14,15 +14,21 @@ struct ClockSettingsView: View { // MARK: - Properties @State private var style: ClockStyle let onCommit: (ClockStyle) -> Void + var onResetOnboarding: (() -> Void)? @State private var digitColor: Color = .white @State private var backgroundColor: Color = .black @State private var showAdvancedSettings = false // 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.onCommit = onCommit + self.onResetOnboarding = onResetOnboarding } // MARK: - Body @@ -92,6 +98,32 @@ struct ClockSettingsView: View { 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 } diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift index e93db46..89db81a 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift @@ -22,7 +22,7 @@ struct ClockOverlayContainer: View { showBattery: style.showBattery, showDate: style.showDate, color: style.effectiveDigitColor, - opacity: style.overlayOpacity, + opacity: style.clockOpacity, dateFormat: style.dateFormat ) .padding(.top, Design.Spacing.small) diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift index b5931e4..0016a92 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift @@ -22,16 +22,6 @@ struct OverlaySection: View { ) 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( title: "Battery Level", subtitle: "Show battery percentage", diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPageView.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPageView.swift new file mode 100644 index 0000000..a8be696 --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPageView.swift @@ -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) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift new file mode 100644 index 0000000..b5f1ff7 --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -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.. 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) +}