diff --git a/AGENTS.md b/AGENTS.md index 3633b39..3a2227c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ -Use /ios-18-role +Use /ios-26-role read the PRD.md read the README.md read the Bedrock README.md as well Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented. -Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator. +Always try to build using xcodebuildmcp after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator. Try and use xcode build mcp if it is working and test using screenshots when asked. \ No newline at end of file diff --git a/PRD.md b/PRD.md index d0db643..ca7ef44 100644 --- a/PRD.md +++ b/PRD.md @@ -22,7 +22,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad - **Full-screen mode** with status bar hiding and tab bar expansion - **Orientation-aware spacing** for optimal layout in all orientations -- **Modern iOS 18+ Animations**: +- **Modern iOS 26 Animations**: - **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch. - **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles). - **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change. @@ -240,7 +240,7 @@ These principles are fundamental to the project's long-term success and must be - **Main App**: `TheNoiseClockApp.swift` - Entry point with WindowGroup - **Branded launch**: AppLaunchView wrapped in a Color.Branding.primary ZStack - **Tab-based navigation**: Three main tabs (Clock, Alarms, Noise) -- **SwiftUI framework**: Modern declarative UI framework with iOS 18+ and iOS 26 features +- **SwiftUI framework**: Modern declarative UI framework with iOS 26 features and Liquid Glass - **Dark theme**: Preferred color scheme set to dark ### Data Models @@ -343,7 +343,7 @@ These principles are fundamental to the project's long-term success and must be ### Visual Design - **Rounded corners**: Modern iOS design language -- **Modern animations**: iOS 18+ smooth and bouncy animations +- **Modern animations**: iOS 26 smooth and bouncy animations - **Color consistency**: Bedrock theme with branded surfaces and accents - **Branded launch**: AppLaunchView with matching launch screen background - **Accessibility**: Proper labels and hidden decorative elements @@ -607,19 +607,19 @@ The following changes **automatically require** PRD updates: ## Technical Requirements ### iOS Compatibility -- **Minimum iOS version**: iOS 18.0+ (Latest SwiftUI features and performance optimizations) +- **Minimum iOS version**: iOS 26.0+ (required for AlarmKit, Liquid Glass, and latest SwiftUI features) - **Target devices**: iPhone and iPad with full adaptive layout support - **Orientation support**: Portrait and landscape with dynamic type support - **Accessibility**: Full VoiceOver and Dynamic Type support ### Modern iOS Technology Stack -- **SwiftUI**: Latest declarative UI framework with iOS 18+ and iOS 26 features +- **SwiftUI**: Latest declarative UI framework with iOS 26 Liquid Glass design - **Observation Framework**: Modern @Observable pattern for state management -- **SwiftData**: Advanced data persistence with iOS 18+ SwiftData features +- **SwiftData**: Advanced data persistence with iOS 26 SwiftData features +- **AlarmKit**: System-level alarms that cut through Focus and Silent modes (iOS 26+) - **Async/Await**: Modern concurrency patterns throughout - **Structured Concurrency**: Task groups and actors for complex operations -- **Swift 6**: Latest language features and safety improvements -- **iOS 26 Features**: Latest platform capabilities where available +- **Swift 6.2**: Latest language features with approachable concurrency ### Dependencies - **AudioPlaybackKit**: Local Swift package for reusable audio functionality @@ -776,22 +776,22 @@ The following simulators are available for testing: ### Project Information - **Created**: September 7, 2025 -- **Framework**: SwiftUI with iOS 18.0+ target (latest stable features) +- **Framework**: SwiftUI with iOS 26.0+ target - **Architecture**: Modern SwiftUI with @Observable pattern, MVVM, and Swift Package modularity - **Package Architecture**: AudioPlaybackKit Swift package for reusable audio functionality - **Testing**: Comprehensive unit and UI test targets with Swift Testing - **Version control**: Git repository with feature branch workflow - **Performance**: Optimized for battery life and smooth operation -- **Modern iOS**: Uses latest iOS 18+ and iOS 26 features with Swift 6 language improvements +- **Modern iOS**: Uses iOS 26 features including AlarmKit and Liquid Glass with Swift 6.2 - **Code Reusability**: Modular Swift package architecture enables code reuse across projects ### Modern iOS Development Practices -- **Swift 6**: Latest language features including strict concurrency checking +- **Swift 6.2**: Latest language features with approachable concurrency - **Async/Await**: Modern concurrency patterns throughout the codebase - **Observation Framework**: @Observable for reactive state management -- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 18+ features +- **SwiftUI Navigation**: Latest NavigationStack and navigation APIs with iOS 26 features - **Accessibility**: Full VoiceOver and Dynamic Type support with iOS 26 enhancements -- **Adaptive Layout**: Support for all device sizes and orientations +- **Adaptive Layout**: Support for all device sizes and orientations with Liquid Glass - **Performance**: Optimized for 120Hz ProMotion displays and iOS 26 performance improvements - **Memory Management**: ARC with proper weak references and cleanup - **Error Handling**: Result types and proper error propagation diff --git a/README.md b/README.md index 365408f..2543c38 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Glow and opacity controls for low-light comfort - Clock tab hides the status bar for a distraction-free display - Selectable animation styles: None, Spring, Bounce, and Glitch -- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons +- Modern iOS 26 animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons - Automatic full-screen: UI fades out after 5 seconds of inactivity, tap to restore - Optional mini-controls for alarms and white noise directly on the clock face diff --git a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 79358ba..2d2817a 100644 --- a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ TheNoiseClock.xcscheme_^#shared#^_ orderHint - 3 + 1 TheNoiseClockWidget.xcscheme_^#shared#^_ orderHint - 2 + 0 diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index 1c20f6b..b0fe133 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { // MARK: - Properties - private enum Tab: Hashable, CustomStringConvertible { + private enum AppTab: Hashable, CustomStringConvertible { case clock case alarms case noise @@ -29,7 +29,7 @@ struct ContentView: View { } } - @State private var selectedTab: Tab = .clock + @State private var selectedTab: AppTab = .clock @State private var clockViewModel = ClockViewModel() @State private var alarmViewModel = AlarmViewModel() @State private var onboardingState = OnboardingState(appIdentifier: "TheNoiseClock") @@ -48,48 +48,40 @@ struct ContentView: View { ZStack { // Main tab content TabView(selection: $selectedTab) { - NavigationStack { - // Pass isOnClockTab so ClockView can make the right tab bar decision - // Tab bar hides ONLY when: isOnClockTab && isDisplayMode - // This prevents race conditions on tab switch - ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab) + Tab("Clock", systemImage: "clock", value: AppTab.clock) { + NavigationStack { + // Pass isOnClockTab so ClockView can make the right tab bar decision + // Tab bar hides ONLY when: isOnClockTab && isDisplayMode + // This prevents race conditions on tab switch + ClockView(viewModel: clockViewModel, isOnClockTab: isOnClockTab) + } } - .tabItem { - Label("Clock", systemImage: "clock") - } - .tag(Tab.clock) - NavigationStack { - AlarmView(viewModel: alarmViewModel) + Tab("Alarms", systemImage: "alarm", value: AppTab.alarms) { + NavigationStack { + AlarmView(viewModel: alarmViewModel) + } } - .tabItem { - Label("Alarms", systemImage: "alarm") - } - .tag(Tab.alarms) - NavigationStack { - NoiseView() + Tab("Noise", systemImage: "waveform", value: AppTab.noise) { + NavigationStack { + NoiseView() + } } - .tabItem { - Label("Noise", systemImage: "waveform") - } - .tag(Tab.noise) - NavigationStack { - ClockSettingsView( - style: clockViewModel.style, - onCommit: { newStyle in - clockViewModel.updateStyle(newStyle) - }, - onResetOnboarding: { - onboardingState.reset() - } - ) + Tab("Settings", systemImage: "gearshape", value: AppTab.settings) { + 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 Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") @@ -100,7 +92,7 @@ struct ContentView: View { clockViewModel.setFullScreenMode(false) } } - .accentColor(AppAccent.primary) + .tint(AppAccent.primary) .background(Color.Branding.primary.ignoresSafeArea()) // Note: AlarmKit handles the alarm UI via the system Lock Screen and Dynamic Island. // No in-app alarm screen is needed - users interact with alarms via the system UI. diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift index 3013013..93e696b 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmSoundService.swift @@ -97,7 +97,7 @@ class AlarmSoundService { } /// Get alarm sound categories - func getAlarmSoundCategories() -> [TheNoiseClock.SoundCategory] { + func getAlarmSoundCategories() -> [SoundCategory] { return [.alarm] } diff --git a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift index 47ad189..a134e0a 100644 --- a/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AddAlarmView.swift @@ -27,7 +27,7 @@ struct AddAlarmView: View { @State private var volume: Float = 1.0 var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 0) { // Time picker section at top TimePickerSection(selectedTime: $newAlarmTime) @@ -48,12 +48,12 @@ struct AddAlarmView: View { NavigationLink(destination: LabelEditView(label: $alarmLabel)) { HStack { Image(systemName: "textformat") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .frame(width: 24) Text("Label") Spacer() Text(alarmLabel) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -61,12 +61,12 @@ struct AddAlarmView: View { NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) { HStack { Image(systemName: "message") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .frame(width: 24) Text("Message") Spacer() Text(notificationMessage) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) } } @@ -75,12 +75,12 @@ struct AddAlarmView: View { NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) { HStack { Image(systemName: "music.note") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .frame(width: 24) Text("Sound") Spacer() Text(getSoundDisplayName(selectedSoundName)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -88,12 +88,12 @@ struct AddAlarmView: View { NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { HStack { Image(systemName: "clock.arrow.circlepath") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .frame(width: 24) Text("Snooze") Spacer() Text("for \(snoozeDuration) min") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -107,7 +107,7 @@ struct AddAlarmView: View { Button("Cancel") { isPresented = false } - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) } ToolbarItem(placement: .navigationBarTrailing) { @@ -127,7 +127,7 @@ struct AddAlarmView: View { isPresented = false } } - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .fontWeight(.semibold) } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift index 0638296..4998c5f 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift @@ -26,15 +26,15 @@ struct AlarmRowView: View { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { Text(alarm.formattedTime()) .font(.headline) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Text(alarm.label) .font(.subheadline) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") .font(.caption) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) if alarm.isEnabled && !isKeepAwakeEnabled { HStack(spacing: Design.Spacing.xSmall) { diff --git a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift index 31edf4d..86aa861 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift @@ -27,18 +27,18 @@ struct EmptyAlarmsView: View { Image(systemName: "alarm.fill") .font(.system(size: 40)) - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .symbolEffect(.bounce, value: true) } VStack(spacing: Design.Spacing.small) { Text("No Alarms Set") .typography(.title2Bold) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Text("Create an alarm to wake up gently on your own terms.") .typography(.body) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) .multilineTextAlignment(.center) .padding(.horizontal, Design.Spacing.xxLarge) } @@ -54,7 +54,7 @@ struct EmptyAlarmsView: View { .padding(.horizontal, Design.Spacing.large) .padding(.vertical, Design.Spacing.medium) .background(AppAccent.primary) - .cornerRadius(Design.CornerRadius.medium) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4) } .padding(.top, Design.Spacing.medium) diff --git a/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift index b9cfdf1..abcf724 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift @@ -21,11 +21,11 @@ struct SnoozeSelectionView: View { ForEach(snoozeOptions, id: \.self) { duration in HStack { Text("\(duration) minutes") - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Spacer() if snoozeDuration == duration { Image(systemName: "checkmark") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) } } .contentShape(Rectangle()) diff --git a/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift index 1c5f2ff..b812619 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift @@ -28,11 +28,11 @@ struct SoundSelectionView: View { HStack { Text(sound.name) .font(.body) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Spacer() if selectedSound == sound.fileName { Image(systemName: "checkmark") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) } } .contentShape(Rectangle()) @@ -62,7 +62,7 @@ struct SoundSelectionView: View { } }) { Image(systemName: isPlaying && currentlyPlayingSound == selectedSound ? "stop.fill" : "play.fill") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) } } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift index f909a41..63801ff 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift @@ -15,15 +15,15 @@ struct TimeUntilAlarmSection: View { VStack(spacing: 4) { HStack { Image(systemName: "calendar") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) Text(timeUntilAlarm) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Text(dayText) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index a87ad0a..11dbb62 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -46,7 +46,7 @@ struct EditAlarmView: View { // MARK: - Body var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 0) { // Time picker section at top TimePickerSection(selectedTime: $alarmTime) @@ -136,7 +136,7 @@ struct EditAlarmView: View { Button("Cancel") { dismiss() } - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) } ToolbarItem(placement: .navigationBarTrailing) { @@ -158,7 +158,7 @@ struct EditAlarmView: View { dismiss() } } - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .fontWeight(.semibold) } } diff --git a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift index 16002d3..678605c 100644 --- a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift +++ b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift @@ -11,7 +11,7 @@ import Bedrock /// Clock customization settings and data model @Observable -class ClockStyle: Codable, Equatable { +final class ClockStyle: Codable, Equatable { // MARK: - Time Format Settings var use24Hour: Bool = false diff --git a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift index 9451f06..d7a3c46 100644 --- a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift +++ b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift @@ -14,7 +14,7 @@ import Bedrock /// ViewModel for clock display and management @Observable -class ClockViewModel { +final class ClockViewModel { // MARK: - Properties private(set) var currentTime = Date() diff --git a/TheNoiseClock/Features/Clock/Views/Components/BatteryOverlayView.swift b/TheNoiseClock/Features/Clock/Views/Components/BatteryOverlayView.swift index b6bdc7e..d61a961 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/BatteryOverlayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/BatteryOverlayView.swift @@ -24,9 +24,9 @@ struct BatteryOverlayView: View { HStack(spacing: 4) { Image(systemName: batteryIcon) - .foregroundColor(batteryColor) + .foregroundStyle(batteryColor) Text("\(batteryLevel)%") - .foregroundColor(color) + .foregroundStyle(color) } .opacity(clamped) .font(.callout.weight(.semibold)) @@ -75,212 +75,15 @@ struct BatteryOverlayView: View { } // MARK: - Previews -#Preview("Battery Overlay - Full Charge") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 100, - isCharging: false - ) -} -#Preview("Battery Overlay - High Charge") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: false - ) -} - -#Preview("Battery Overlay - Medium Charge") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 50, - isCharging: false - ) -} - -#Preview("Battery Overlay - Low Charge") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 25, - isCharging: false - ) -} - -#Preview("Battery Overlay - Critical Charge") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 10, - isCharging: false - ) -} - -#Preview("Battery Overlay - Charging") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 85, - isCharging: true - ) -} - -#Preview("Battery Overlay - Charging States") { +#Preview("Battery Overlay - All States") { VStack(spacing: 20) { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: false - ) - - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: true - ) - - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 25, - isCharging: false - ) - - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 25, - isCharging: true - ) + BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 100, isCharging: false) + BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 50, isCharging: false) + BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 15, isCharging: false) + BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 5, isCharging: false) + BatteryOverlayView(color: .white, opacity: 1.0, batteryLevel: 75, isCharging: true) } .padding() .background(Color.black) } - -#Preview("Battery Overlay - Different Colors") { - VStack(spacing: 20) { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: false - ) - - BatteryOverlayView( - color: .blue, - opacity: 1.0, - batteryLevel: 50, - isCharging: false - ) - - BatteryOverlayView( - color: .green, - opacity: 1.0, - batteryLevel: 25, - isCharging: false - ) - - BatteryOverlayView( - color: .red, - opacity: 1.0, - batteryLevel: 10, - isCharging: false - ) - } - .padding() - .background(Color.black) -} - -#Preview("Battery Overlay - Different Opacities") { - VStack(spacing: 20) { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: false - ) - - BatteryOverlayView( - color: .white, - opacity: 0.7, - batteryLevel: 50, - isCharging: false - ) - - BatteryOverlayView( - color: .white, - opacity: 0.5, - batteryLevel: 25, - isCharging: false - ) - - BatteryOverlayView( - color: .white, - opacity: 0.3, - batteryLevel: 10, - isCharging: false - ) - } - .padding() - .background(Color.black) -} - -#Preview("Battery Overlay - Dark Background") { - BatteryOverlayView( - color: .white, - opacity: 1.0, - batteryLevel: 75, - isCharging: false - ) - .padding() - .background(Color.black) -} - -#Preview("Battery Overlay - Light Background") { - BatteryOverlayView( - color: .black, - opacity: 1.0, - batteryLevel: 50, - isCharging: false - ) - .padding() - .background(Color.white) -} - -#Preview("Battery Overlay - Clock Context") { - ZStack { - // Simulate clock background - Color.black - .ignoresSafeArea() - - VStack { - Spacer() - - // Simulate clock display - Text("12:34") - .font(.system(size: 80, weight: .bold, design: .rounded)) - .foregroundColor(.white) - - Spacer() - - // Battery overlay at bottom - HStack { - Spacer() - BatteryOverlayView( - color: .white, - opacity: 0.8, - batteryLevel: 75, - isCharging: false - ) - Spacer() - } - .padding(.bottom, 50) - } - } -} diff --git a/TheNoiseClock/Features/Clock/Views/Components/DateOverlayView.swift b/TheNoiseClock/Features/Clock/Views/Components/DateOverlayView.swift index 29a2263..206af64 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/DateOverlayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/DateOverlayView.swift @@ -25,7 +25,7 @@ struct DateOverlayView: View { let clamped = ColorUtils.clampOpacity(opacity) Text(dateString) - .foregroundColor(color) + .foregroundStyle(color) .opacity(clamped) .font(.callout.weight(.semibold)) .onAppear { diff --git a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift index 05119ea..42a3f92 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift @@ -65,7 +65,7 @@ struct DigitView: View { private var glowText: some View { baseText - .foregroundColor(digitColor) + .foregroundStyle(digitColor) .blur(radius: glowRadius) .opacity(glowOpacity) .modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity)) @@ -73,7 +73,7 @@ struct DigitView: View { private var mainText: some View { baseText - .foregroundColor(digitColor) + .foregroundStyle(digitColor) .opacity(opacity) .modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize)) .debugBorder(Self.debugShowBorders, color: .orange, label: "Text") diff --git a/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift b/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift index d47ff67..e1361d2 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift @@ -34,12 +34,12 @@ struct NextAlarmOverlay: View { Text(alarmString) .typography(.calloutEmphasis) } - .foregroundColor(color) + .foregroundStyle(color) .opacity(opacity) .padding(.horizontal, Design.Spacing.small) .padding(.vertical, Design.Spacing.xxSmall) .background(AppSurface.overlay.opacity(0.3)) - .cornerRadius(Design.CornerRadius.small) + .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift b/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift index 849bed6..b8c130c 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift @@ -27,21 +27,21 @@ struct NoiseMiniPlayer: View { Button(action: onToggle) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) + .foregroundStyle(.white) .frame(width: 28, height: 28) - .background(isPlaying ? AppAccent.primary.opacity(0.8) : AppAccent.primary.opacity(0.8)) + .background(AppAccent.primary.opacity(0.8)) .clipShape(Circle()) } VStack(alignment: .leading, spacing: 0) { Text(isPlaying ? "Playing" : "Paused") .font(.system(size: 8, weight: .bold)) - .foregroundColor(color.opacity(0.6)) + .foregroundStyle(color.opacity(0.6)) .textCase(.uppercase) Text(name) .typography(.calloutEmphasis) - .foregroundColor(color) + .foregroundStyle(color) .lineLimit(1) } } @@ -49,7 +49,7 @@ struct NoiseMiniPlayer: View { .padding(.trailing, Design.Spacing.medium) .padding(.vertical, Design.Spacing.xxSmall) .background(AppSurface.overlay.opacity(0.3)) - .cornerRadius(Design.CornerRadius.appLarge) + .clipShape(.rect(cornerRadius: Design.CornerRadius.appLarge)) .opacity(opacity) } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift index c222716..ea8c170 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift @@ -170,7 +170,7 @@ struct TimeDisplayView: View { size: max(12, fontSize * 0.18) ) ) - .foregroundColor(digitColor) + .foregroundStyle(digitColor) .opacity(clockOpacity) .padding(.horizontal, max(6, fontSize * 0.05)) .padding(.vertical, max(3, fontSize * 0.03)) diff --git a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift index f3ac272..5cdd6d5 100644 --- a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift +++ b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift @@ -17,7 +17,7 @@ struct SoundCategoryView: View { @Binding var selectedSound: Sound? @State private var selectedCategory: SoundCategory = .all @Binding var searchText: String - @State private var viewModel = SoundViewModel() + @State private var previewViewModel = SoundViewModel() // MARK: - Computed Properties private var filteredSounds: [Sound] { @@ -103,13 +103,13 @@ struct SoundCategoryView: View { SoundCard( sound: sound, isSelected: selectedSound?.id == sound.id, - isPreviewing: viewModel.isPreviewing && viewModel.previewSound?.id == sound.id, + isPreviewing: previewViewModel.isPreviewing && previewViewModel.previewSound?.id == sound.id, onSelect: { - viewModel.selectSound(sound) + previewViewModel.selectSound(sound) selectedSound = sound }, onPreview: { - viewModel.previewSound(sound) + previewViewModel.previewSound(sound) } ) } @@ -144,8 +144,8 @@ struct CategoryTab: View { .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) .background(isSelected ? AppAccent.primary : AppSurface.card) - .foregroundColor(isSelected ? .white : AppTextColors.primary) - .cornerRadius(Design.CornerRadius.medium) + .foregroundStyle(isSelected ? .white : AppTextColors.primary) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1) @@ -173,7 +173,7 @@ struct SoundCard: View { ZStack { Image(systemName: soundIcon) .font(.title3) - .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) + .foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary) if isPreviewing { Circle() @@ -189,7 +189,7 @@ struct SoundCard: View { VStack(alignment: .leading, spacing: 2) { Text(sound.name) .styled(.subheadingEmphasis) - .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) + .foregroundStyle(isSelected ? AppAccent.primary : AppTextColors.primary) .lineLimit(1) Text(sound.description) diff --git a/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift b/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift index 18e90a9..dc14a0c 100644 --- a/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift +++ b/TheNoiseClock/Features/Noise/Views/Components/SoundControlView.swift @@ -27,11 +27,11 @@ struct SoundControlView: View { VStack(alignment: .leading, spacing: 4) { Text(sound.name) .font(.headline.weight(.semibold)) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Text(sound.description) .font(.caption) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) .lineLimit(2) } @@ -40,7 +40,7 @@ struct SoundControlView: View { // Category badge Text(sound.category.capitalized) .font(.caption2.weight(.medium)) - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(AppAccent.primary, in: Capsule()) @@ -60,11 +60,11 @@ struct SoundControlView: View { HStack(spacing: Design.Spacing.small) { Image(systemName: isPlaying ? "stop.fill" : "play.fill") .font(.title2.weight(.semibold)) - .foregroundColor(.white) + .foregroundStyle(.white) Text(isPlaying ? "Stop Sound" : "Play Sound") .font(.headline.weight(.semibold)) - .foregroundColor(.white) + .foregroundStyle(.white) } .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.medium) .background( diff --git a/TheNoiseClock/Features/Noise/Views/NoiseView.swift b/TheNoiseClock/Features/Noise/Views/NoiseView.swift index 7cbdbae..6b9b875 100644 --- a/TheNoiseClock/Features/Noise/Views/NoiseView.swift +++ b/TheNoiseClock/Features/Noise/Views/NoiseView.swift @@ -42,22 +42,22 @@ struct NoiseView: View { HStack { HStack(spacing: Design.Spacing.small) { Image(systemName: "magnifyingglass") - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) TextField("Search sounds", text: $searchText) .textFieldStyle(.plain) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) if !searchText.isEmpty { Button(action: { searchText = "" }) { Image(systemName: "xmark.circle.fill") - .foregroundColor(AppTextColors.tertiary) + .foregroundStyle(AppTextColors.tertiary) } } } .padding(Design.Spacing.small) .background(AppSurface.overlay) - .cornerRadius(Design.CornerRadius.medium) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } .padding(.horizontal, Design.Spacing.large) .padding(.top, Design.Spacing.medium) @@ -118,18 +118,18 @@ struct NoiseView: View { Image(systemName: "waveform") .font(.title) - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .symbolEffect(.variableColor.iterative, options: .repeating) } VStack(spacing: 4) { Text("Ready for Sleep?") .typography(.title3Bold) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Text("Select a soothing sound below to begin your relaxation journey.") .typography(.caption) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) .multilineTextAlignment(.center) } } @@ -184,18 +184,18 @@ struct NoiseView: View { Image(systemName: "waveform") .font(.title) - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .symbolEffect(.variableColor.iterative, options: .repeating) } VStack(spacing: 4) { Text("Ready for Sleep?") .typography(.title3Bold) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Text("Select a soothing sound to begin.") .typography(.caption) - .foregroundColor(AppTextColors.secondary) + .foregroundStyle(AppTextColors.secondary) .multilineTextAlignment(.center) } } diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift new file mode 100644 index 0000000..c86ef34 --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingBottomControls.swift @@ -0,0 +1,78 @@ +// +// OnboardingBottomControls.swift +// TheNoiseClock +// +// Bottom navigation controls for onboarding flow. +// + +import SwiftUI +import Bedrock + +/// Bottom controls with page indicators and navigation buttons +struct OnboardingBottomControls: View { + + @Binding var currentPage: Int + let totalPages: Int + let onSkip: () -> Void + let onFinish: () -> Void + + var body: some View { + VStack(spacing: Design.Spacing.large) { + // Page indicators + HStack(spacing: Design.Spacing.small) { + ForEach(0.. 0 { + withAnimation { currentPage -= 1 } + } else { + onSkip() + } + } label: { + Text(currentPage == 0 ? "Skip" : "Back") + .typography(.bodyEmphasis) + .foregroundStyle(AppTextColors.secondary) + .frame(maxWidth: .infinity) + .padding(Design.Spacing.medium) + } + + Button { + if currentPage < totalPages - 1 { + withAnimation { currentPage += 1 } + } else { + onFinish() + } + } label: { + Text(currentPage == totalPages - 1 ? "Get Started" : "Next") + .typography(.bodyEmphasis) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(Design.Spacing.medium) + .background(AppAccent.primary) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + } + } + } +} + +// MARK: - Preview + +#Preview { + OnboardingBottomControls( + currentPage: .constant(0), + totalPages: 4, + onSkip: {}, + onFinish: {} + ) + .padding() + .preferredColorScheme(.dark) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingFeatureRow.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingFeatureRow.swift new file mode 100644 index 0000000..d69e44e --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingFeatureRow.swift @@ -0,0 +1,44 @@ +// +// OnboardingFeatureRow.swift +// TheNoiseClock +// +// Reusable feature highlight row for onboarding pages. +// + +import SwiftUI +import Bedrock + +/// Reusable row displaying an icon and text for onboarding feature highlights +struct OnboardingFeatureRow: View { + + let icon: String + let text: String + var iconSize: CGFloat = 20 + var iconWidth: CGFloat = 28 + var body: some View { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: icon) + .font(.system(size: iconSize)) + .foregroundStyle(AppAccent.primary) + .frame(width: iconWidth) + + Text(text) + .typography(.body) + .foregroundStyle(AppTextColors.secondary) + } + .frame(maxWidth: 320, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, Design.Spacing.xxLarge) + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: Design.Spacing.medium) { + OnboardingFeatureRow(icon: "moon.stars.fill", text: "Fall asleep to soothing sounds") + OnboardingFeatureRow(icon: "alarm.fill", text: "Wake up gently, on your terms") + OnboardingFeatureRow(icon: "clock.fill", text: "Automatic full-screen display") + } + .preferredColorScheme(.dark) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingGetStartedPage.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingGetStartedPage.swift new file mode 100644 index 0000000..dd0ec9f --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingGetStartedPage.swift @@ -0,0 +1,57 @@ +// +// OnboardingGetStartedPage.swift +// TheNoiseClock +// +// Final "Get Started" page for onboarding. +// + +import SwiftUI +import Bedrock + +/// Page 4: Get Started with quick tips +struct OnboardingGetStartedPage: View { + + var body: some View { + VStack(spacing: Design.Spacing.xxLarge) { + Spacer() + + 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) + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + OnboardingFeatureRow(icon: "alarm.fill", text: "Create your first alarm") + OnboardingFeatureRow(icon: "clock.fill", text: "Wait 5s for full screen") + OnboardingFeatureRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds") + } + .padding(.top, Design.Spacing.medium) + + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Preview + +#Preview { + OnboardingGetStartedPage() + .preferredColorScheme(.dark) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift new file mode 100644 index 0000000..d11c44e --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingPermissionsPage.swift @@ -0,0 +1,179 @@ +// +// OnboardingPermissionsPage.swift +// TheNoiseClock +// +// AlarmKit permissions page for onboarding. +// + +import SwiftUI +import Bedrock + +/// Page 3: AlarmKit permission request +struct OnboardingPermissionsPage: View { + + @Binding var alarmKitPermissionGranted: Bool + @Binding var keepAwakeEnabled: Bool + let onAdvanceToFinal: () -> Void + + var body: some View { + VStack(spacing: Design.Spacing.xxLarge) { + Spacer() + + 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) + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + OnboardingFeatureRow(icon: "moon.zzz.fill", text: "Cuts through Do Not Disturb") + OnboardingFeatureRow(icon: "lock.iphone", text: "Shows countdown on Lock Screen") + OnboardingFeatureRow(icon: "iphone.badge.play", text: "Works when app is closed") + } + .padding(.top, Design.Spacing.medium) + + permissionButton + .padding(.top, Design.Spacing.large) + + keepAwakeSection + .padding(.top, Design.Spacing.medium) + + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + keepAwakeEnabled = isKeepAwakeEnabled() + } + } + + // MARK: - Permission Button + + @ViewBuilder + private var permissionButton: some View { + if alarmKitPermissionGranted { + 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)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } else { + 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) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + } + } + + // MARK: - Keep Awake Section + + 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) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + .disabled(keepAwakeEnabled) + } + } + + // MARK: - Actions + + private func requestAlarmKitPermission() { + Task { + let granted = await AlarmKitService.shared.requestAuthorization() + withAnimation(.spring(duration: 0.3)) { + alarmKitPermissionGranted = granted + } + if granted { + try? await Task.sleep(for: .milliseconds(800)) + onAdvanceToFinal() + } + } + } + + private func enableKeepAwake() { + let 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) + } + } +} + +// MARK: - Preview + +#Preview { + OnboardingPermissionsPage( + alarmKitPermissionGranted: .constant(false), + keepAwakeEnabled: .constant(false), + onAdvanceToFinal: {} + ) + .preferredColorScheme(.dark) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingWelcomePage.swift b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingWelcomePage.swift new file mode 100644 index 0000000..ebbbcaf --- /dev/null +++ b/TheNoiseClock/Features/Onboarding/Views/Components/OnboardingWelcomePage.swift @@ -0,0 +1,84 @@ +// +// OnboardingWelcomePage.swift +// TheNoiseClock +// +// Welcome page with live clock preview for onboarding. +// + +import SwiftUI +import Bedrock + +/// Page 1: Welcome with live clock preview +struct OnboardingWelcomePage: View { + + var body: some View { + VStack(spacing: Design.Spacing.large) { + Spacer() + + 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) + + VStack(spacing: Design.Spacing.medium) { + OnboardingFeatureRow( + icon: "moon.stars.fill", + text: "Fall asleep to soothing sounds" + ) + OnboardingFeatureRow( + icon: "alarm.fill", + text: "Wake up gently, on your terms" + ) + OnboardingFeatureRow( + icon: "clock.fill", + text: "Automatic full-screen display" + ) + } + .padding(.top, Design.Spacing.large) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Onboarding Clock Text + +/// Separate view for TimelineView content to avoid view builder issues +struct OnboardingClockText: View { + let date: Date + + private static let formatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "h:mm" + return df + }() + + private var timeString: String { + Self.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 { + OnboardingWelcomePage() + .preferredColorScheme(.dark) +} diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift index fd13441..53fd572 100644 --- a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -31,423 +31,54 @@ struct OnboardingView: View { var body: some View { ZStack { - // Background AppSurface.primary .ignoresSafeArea() VStack(spacing: 0) { - // Page content TabView(selection: $currentPage) { - welcomeWithClockPage + OnboardingWelcomePage() .tag(0) - whiteNoisePage - .tag(1) + 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." + ) + .tag(1) - permissionsPage - .tag(2) + OnboardingPermissionsPage( + alarmKitPermissionGranted: $alarmKitPermissionGranted, + keepAwakeEnabled: $keepAwakeEnabled, + onAdvanceToFinal: { + withAnimation { currentPage = 3 } + } + ) + .tag(2) - getStartedPage + OnboardingGetStartedPage() .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) + OnboardingBottomControls( + currentPage: $currentPage, + totalPages: totalPages, + onSkip: onComplete, + onFinish: { + withAnimation(.spring(duration: 0.45, bounce: 0.2)) { + onComplete() + } + } + ) + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.bottom, Design.Spacing.xxLarge) } .frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: .infinity, alignment: .center) } } - - // 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) - } - .frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading - .frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent - .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) - } - .frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading - .frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent - .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) - } - .frame(maxWidth: 320, alignment: .leading) // Constrain width and align content to leading - .frame(maxWidth: .infinity, alignment: .center) // Center the constrained box in the parent - .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: - 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() { - // Snappier transition for a better feel - withAnimation(.spring(duration: 0.45, bounce: 0.2)) { - 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 diff --git a/TheNoiseClock/Shared/Components/SettingsSelectionView.swift b/TheNoiseClock/Shared/Components/SettingsSelectionView.swift index 925df77..08e2cb6 100644 --- a/TheNoiseClock/Shared/Components/SettingsSelectionView.swift +++ b/TheNoiseClock/Shared/Components/SettingsSelectionView.swift @@ -39,11 +39,11 @@ struct SettingsSelectionView: View { HStack { Text(toString(option)) .typography(.body) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) Spacer() if selection == option { Image(systemName: "checkmark") - .foregroundColor(AppAccent.primary) + .foregroundStyle(AppAccent.primary) .font(.body.bold()) } } diff --git a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift index 15f670c..c12dc52 100644 --- a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift +++ b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift @@ -199,170 +199,3 @@ struct FontUtils { return baseName + (weightSuffix.isEmpty ? "" : "-" + weightSuffix) } } - -private struct TestContentView: View { - @State private var digit: String = "138" - @State private var previewFontSize: CGFloat = 1000 - @State private var fontWeight: Font.Weight = .bold - @State private var fontDesign: Font.Design = .rounded - - private var date: Date { - let hours = ["13"].randomElement() ?? "08" - let minutes = ["38"].randomElement() ?? "33" - let timeString = "\(hours):\(minutes)" - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - let todayString = formatter.string(from: Date()) - let fullDateString = "\(todayString) \(timeString)" - - formatter.dateFormat = "yyyy-MM-dd HH:mm" - return formatter.date(from: fullDateString) ?? Date() - } - - var body: some View { - let newDate = date - return ScrollView { - VStack(spacing: 20) { - ForEach(FontFamily.allCases, id: \.rawValue) { (font: FontFamily) in - VStack { - Text(font.rawValue) - .font(Font.system(size: 16, weight: Font.Weight.bold)) - .padding(.bottom, 5) - - TimeDisplayView(date: newDate, - use24Hour: true, - showSeconds: false, - showAmPm: true, - digitColor: .primary, - glowIntensity: 0.5, - clockOpacity: 1.0, - fontFamily: font, - fontWeight: fontWeight, - fontDesign: fontDesign, - forceHorizontalMode: true, - isDisplayMode: false, - animationStyle: .spring) - - TimeSegment( - text: digit, - fontSize: .constant(129), // CGFloat - opacity: 1.0, // Double - digitColor: Color.primary, // Color - glowIntensity: 0.5, // Double - fontFamily: font, // FontFamily - fontWeight: fontWeight, - fontDesign: fontDesign, - isDisplayMode: false, - animationStyle: .spring - ) - } - .frame(width: 400, height: 200) - .border(Color.black) - - } - } - .padding() - } - } -} - -struct FontCombinationsPreview: View { - // All designs - private let designs: [Font.Design] = Font.Design.allCases - - private let columns: [GridItem] = [ - GridItem(.flexible(), spacing: 20), - GridItem(.flexible(), spacing: 20), - GridItem(.flexible(), spacing: 20) - ] - - var body: some View { - // Precompute local copies to avoid type-checker stress - let fonts: [FontFamily] = FontFamily.allCases.sorted { - $0.rawValue.localizedCaseInsensitiveCompare($1.rawValue) - == .orderedAscending - } - let designsLocal: [Font.Design] = designs - - return ScrollView { - LazyVGrid(columns: columns, spacing: 20) { - ForEach(fonts, id: \.self) { font in - ForEach(font.fontWeights, id: \.self) { weight in - ForEach(designsLocal, id: \.self) { design in - FontSampleCell(font: font, weight: weight, design: design) - } - } - } - } - .padding() - } - } -} - -private struct FontSampleCell: View { - let font: FontFamily - let weight: Font.Weight - let design: Font.Design - - var body: some View { - VStack(alignment: .center, spacing: 5) { - Text("\(font.rawValue), \(weight.rawValue), \(design.rawValue)") - .font(.system(size: 12, weight: .medium)) - .multilineTextAlignment(.center) - .padding(.bottom, 5) - TimeSegment( - text: "38", - fontSize: .constant(129), // CGFloat - opacity: 1.0, // Double - digitColor: Color.primary, // Color - glowIntensity: 0.5, // Double - fontFamily: font, // FontFamily - fontWeight: weight, - fontDesign: design, - isDisplayMode: false, - animationStyle: .spring) - .frame(width: 100, height: 100) - .border(Color.black) - } - } -} - - -#Preview { - FontCombinationsPreview() -} - - -// MARK: - Descriptions for Font.Weight and Font.Design -private extension Font.Weight { - var description: String { - switch self { - case .ultraLight: return "ultraLight" - case .thin: return "thin" - case .light: return "light" - case .regular: return "regular" - case .medium: return "medium" - case .semibold: return "semibold" - case .bold: return "bold" - case .heavy: return "heavy" - case .black: return "black" - default: - // Fallback for any future/unknown cases - return "custom" - } - } -} - -private extension Font.Design { - var description: String { - switch self { - case .default: return "default" - case .serif: return "serif" - case .rounded: return "rounded" - case .monospaced: return "monospaced" - @unknown default: - return "unknown" - } - } -} diff --git a/TheNoiseClock/Shared/Extensions/View+Extensions.swift b/TheNoiseClock/Shared/Extensions/View+Extensions.swift index 6d7b45b..ccd2d1d 100644 --- a/TheNoiseClock/Shared/Extensions/View+Extensions.swift +++ b/TheNoiseClock/Shared/Extensions/View+Extensions.swift @@ -30,8 +30,8 @@ extension View { self .padding(Design.Spacing.medium) .background(isEnabled ? color : color.opacity(Design.Opacity.light)) - .foregroundColor(.white) - .cornerRadius(Design.CornerRadius.small) + .foregroundStyle(.white) + .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) .disabled(!isEnabled) } @@ -68,7 +68,7 @@ extension View { func sectionTitleStyle() -> some View { self .font(.title2.weight(.bold)) - .foregroundColor(AppTextColors.primary) + .foregroundStyle(AppTextColors.primary) } /// Center content horizontally with spacers