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
|
||||
│ │ │ ├── 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
|
||||
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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,8 +22,13 @@ 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 {
|
||||
ZStack {
|
||||
// Main tab content
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
ClockView(viewModel: clockViewModel)
|
||||
@ -49,9 +55,15 @@ struct ContentView: View {
|
||||
.tag(Tab.noise)
|
||||
|
||||
NavigationStack {
|
||||
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
||||
ClockSettingsView(
|
||||
style: clockViewModel.style,
|
||||
onCommit: { newStyle in
|
||||
clockViewModel.updateStyle(newStyle)
|
||||
},
|
||||
onResetOnboarding: {
|
||||
onboardingState.reset()
|
||||
}
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
@ -65,6 +77,16 @@ struct ContentView: View {
|
||||
}
|
||||
.accentColor(AppAccent.primary)
|
||||
.background(Color.Branding.primary.ignoresSafeArea())
|
||||
|
||||
// Onboarding overlay for first-time users
|
||||
if !onboardingState.hasCompletedWelcome {
|
||||
OnboardingView {
|
||||
onboardingState.completeWelcome()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: onboardingState.hasCompletedWelcome)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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