457 lines
16 KiB
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)
|
|
}
|