Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
01931244e0
commit
a4eaa187e5
23
PRD.md
23
PRD.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
390
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal file
390
TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user