TheNoiseClock/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift

457 lines
16 KiB
Swift

//
// OnboardingView.swift
// TheNoiseClock
//
// Streamlined onboarding flow optimized for time-to-value.
// Shows real clock immediately, requests AlarmKit permission,
// and gets users to their "aha moment" fast.
//
// Updated for AlarmKit (iOS 26+) - alarms now cut through
// Focus modes and silent mode automatically.
//
import SwiftUI
import Bedrock
import Foundation
/// Streamlined onboarding optimized for activation
struct OnboardingView: View {
// MARK: - Properties
let onComplete: () -> Void
@State private var currentPage = 0
@State private var alarmKitPermissionGranted = false
@State private var keepAwakeEnabled = false
private let totalPages = 4
// MARK: - Body
var body: some View {
ZStack {
// Background
AppSurface.primary
.ignoresSafeArea()
VStack(spacing: 0) {
// Page content
TabView(selection: $currentPage) {
welcomeWithClockPage
.tag(0)
whiteNoisePage
.tag(1)
permissionsPage
.tag(2)
getStartedPage
.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage)
// Bottom controls
bottomControls
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.xxLarge)
}
}
}
// 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
TimelineView(.periodic(from: .now, by: 1.0)) { context in
OnboardingClockText(date: context.date)
}
.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: "clock.fill",
text: "Automatic full-screen display"
)
}
.padding(.top, Design.Spacing.large)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
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: White Noise
private var whiteNoisePage: some View {
OnboardingPageView(
icon: "waveform",
iconColor: AppAccent.primary,
title: "Soothing Sounds",
description: "Choose from a variety of white noise, rain, and ambient sounds to help you drift off to sleep."
)
}
// MARK: - Page 3: AlarmKit Permissions
private var permissionsPage: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Alarm icon with animated waves
ZStack {
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "alarm.waves.left.and.right.fill")
.font(.system(size: 50, weight: .medium))
.foregroundStyle(AppAccent.primary)
.symbolEffect(.pulse, options: .repeating)
}
Text("Alarms that actually work")
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text("Works in silent mode, Focus mode, and even when your phone is locked.")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Feature bullets
VStack(alignment: .leading, spacing: Design.Spacing.small) {
alarmFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb")
alarmFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen")
alarmFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed")
}
.padding(.top, Design.Spacing.medium)
// Permission button or success state
permissionButton
.padding(.top, Design.Spacing.large)
// Optional: Keep Awake for bedside clock mode
keepAwakeSection
.padding(.top, Design.Spacing.medium)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
keepAwakeEnabled = isKeepAwakeEnabled()
}
}
private var keepAwakeSection: some View {
VStack(spacing: Design.Spacing.small) {
Text("Want the clock always visible?")
.typography(.callout)
.foregroundStyle(AppTextColors.tertiary)
.multilineTextAlignment(.center)
Button {
enableKeepAwake()
} label: {
HStack(spacing: Design.Spacing.small) {
Image(systemName: keepAwakeEnabled ? "checkmark.circle.fill" : "bolt.fill")
Text(keepAwakeEnabled ? "Keep Awake Enabled" : "Enable Keep Awake")
}
.typography(.callout)
.foregroundStyle(keepAwakeEnabled ? AppStatus.success : AppTextColors.secondary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(keepAwakeEnabled ? AppStatus.success.opacity(0.15) : AppSurface.secondary)
.cornerRadius(Design.CornerRadius.medium)
}
.disabled(keepAwakeEnabled)
}
}
private func alarmFeatureRow(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundStyle(AppAccent.primary)
.frame(width: 28)
Text(text)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
Spacer()
}
.padding(.horizontal, Design.Spacing.xxLarge)
}
private var permissionButton: some View {
Group {
if alarmKitPermissionGranted {
// Success state
HStack(spacing: Design.Spacing.small) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
Text("Alarms enabled!")
}
.foregroundStyle(AppStatus.success)
.typography(.bodyEmphasis)
.padding(Design.Spacing.medium)
.background(AppStatus.success.opacity(0.15))
.cornerRadius(Design.CornerRadius.medium)
} else {
// Request AlarmKit authorization
Button {
requestAlarmKitPermission()
} label: {
HStack {
Image(systemName: "alarm.fill")
Text("Enable Alarms")
}
.typography(.bodyEmphasis)
.foregroundStyle(.white)
.frame(maxWidth: 280)
.padding(Design.Spacing.medium)
.background(AppAccent.primary)
.cornerRadius(Design.CornerRadius.medium)
}
}
}
}
// MARK: - Page 4: 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("Your alarms will work even in silent mode and Focus mode. The interface will automatically fade out to give you a clean view of the time!")
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
// Quick tips
VStack(alignment: .leading, spacing: Design.Spacing.small) {
tipRow(icon: "alarm.fill", text: "Create your first alarm")
tipRow(icon: "clock.fill", text: "Wait 5s for full screen")
tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
}
.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: - Actions
private func requestAlarmKitPermission() {
Task {
// Request AlarmKit authorization (iOS 26+)
let granted = await AlarmKitService.shared.requestAuthorization()
withAnimation(.spring(duration: 0.3)) {
alarmKitPermissionGranted = granted
}
// Auto-advance after permission granted
if granted {
try? await Task.sleep(for: .milliseconds(800))
withAnimation {
currentPage = 3
}
}
}
}
private func enableKeepAwake() {
var style = loadClockStyle()
style.keepAwake = true
saveClockStyle(style)
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
withAnimation(.spring(duration: 0.3)) {
keepAwakeEnabled = true
}
}
private func isKeepAwakeEnabled() -> Bool {
loadClockStyle().keepAwake
}
private func loadClockStyle() -> ClockStyle {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let decoded = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle()
}
return decoded
}
private func saveClockStyle(_ style: ClockStyle) {
if let data = try? JSONEncoder().encode(style) {
UserDefaults.standard.set(data, forKey: ClockStyle.appStorageKey)
}
}
private func triggerCelebration() {
// Use a more subtle transition to the main app
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
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)
}