From e959060af517d5ac8fb008a76afcaf5fde3f904c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 2 Feb 2026 22:13:06 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../Services/SoundPlayer.swift | 8 +- .../ViewModels/SoundViewModel.swift | 4 + PRD.md | 15 +- README.md | 6 +- TheNoiseClock/App/ContentView.swift | 6 +- .../Alarms/Services/AlarmService.swift | 13 +- .../Alarms/State/AlarmViewModel.swift | 2 +- .../Features/Alarms/Views/AlarmView.swift | 81 +++++----- .../Views/Components/AlarmRowView.swift | 75 ++++----- .../Views/Components/EmptyAlarmsView.swift | 54 +++++-- .../Views/Components/LabelEditView.swift | 21 ++- .../NotificationMessageEditView.swift | 41 ++--- .../Components/SnoozeSelectionView.swift | 6 + .../Views/Components/SoundSelectionView.swift | 6 + .../Views/Components/TimePickerSection.swift | 3 +- .../Components/TimeUntilAlarmSection.swift | 3 +- .../Features/Alarms/Views/EditAlarmView.swift | 19 ++- .../Features/Clock/Models/ClockStyle.swift | 10 ++ .../Features/Clock/State/ClockViewModel.swift | 30 ++-- .../Features/Clock/Views/ClockView.swift | 34 ++--- .../Components/ClockDisplayContainer.swift | 10 +- .../Components/ClockGestureHandler.swift | 32 ---- .../Components/ClockOverlayContainer.swift | 6 +- .../Views/Components/NextAlarmOverlay.swift | 55 +++++++ .../Views/Components/NoiseMiniPlayer.swift | 79 ++++++++++ .../Settings/AdvancedAppearanceSection.swift | 93 +++++++----- .../Settings/AdvancedDisplaySection.swift | 56 ++++--- .../Settings/BasicAppearanceSection.swift | 57 ++++--- .../Settings/BasicDisplaySection.swift | 80 ++++++---- .../Components/Settings/FontSection.swift | 97 +++++++----- .../Settings/NightModeSection.swift | 142 +++++++++++------- .../Components/Settings/OverlaySection.swift | 82 +++++++--- .../Views/Components/TopOverlayView.swift | 73 +++++++-- .../Views/Components/SoundCategoryView.swift | 115 ++++++-------- .../Features/Noise/Views/NoiseView.swift | 106 +++++++++---- .../Onboarding/Views/OnboardingView.swift | 46 +++--- .../Components/SettingsSelectionView.swift | 83 ++++++++++ 37 files changed, 1087 insertions(+), 562 deletions(-) delete mode 100644 TheNoiseClock/Features/Clock/Views/Components/ClockGestureHandler.swift create mode 100644 TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift create mode 100644 TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift create mode 100644 TheNoiseClock/Shared/Components/SettingsSelectionView.swift diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift index d7af53d..b795c0b 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/Services/SoundPlayer.swift @@ -19,7 +19,7 @@ public class SoundPlayer { // MARK: - Properties private var players: [String: AVAudioPlayer] = [:] private var currentPlayer: AVAudioPlayer? - private var currentSound: Sound? + public private(set) var currentSound: Sound? private var shouldResumeAfterInterruption = false private let wakeLockService = WakeLockService.shared private let soundConfigurationService = SoundConfigurationService.shared @@ -102,13 +102,17 @@ public class SoundPlayer { public func stopSound() { currentPlayer?.stop() currentPlayer = nil - currentSound = nil shouldResumeAfterInterruption = false // Disable wake lock when stopping audio wakeLockService.disableWakeLock() } + public func clearCurrentSound() { + stopSound() + currentSound = nil + } + // MARK: - Private Methods /// Helper method to get URL for sound file, handling bundles and direct paths diff --git a/AudioPlaybackKit/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift b/AudioPlaybackKit/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift index 6e0fae6..1c1528e 100644 --- a/AudioPlaybackKit/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift +++ b/AudioPlaybackKit/Sources/AudioPlaybackKit/ViewModels/SoundViewModel.swift @@ -19,6 +19,8 @@ public class SoundViewModel { public var isPreviewing: Bool = false public var previewSound: Sound? + public var selectedSound: Sound? + public var isPlaying: Bool { soundPlayer.isPlaying } @@ -49,6 +51,8 @@ public class SoundViewModel { } // Stop any preview stopPreview() + + selectedSound = sound } // MARK: - Preview Functionality diff --git a/PRD.md b/PRD.md index 1033e39..d0db643 100644 --- a/PRD.md +++ b/PRD.md @@ -47,17 +47,18 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di ### 3. Display Modes - **Normal mode**: Standard interface with navigation and settings -- **Display mode**: Full-screen clock activated by long-press (0.6 seconds) -- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode +- **Automatic UI hiding**: Tab bar and navigation elements automatically hide after 5 seconds of inactivity on the Clock tab +- **Interaction**: Any tap on the screen restores the UI and resets the idle timer - **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding - **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar) - **Smooth transitions**: Animated transitions between modes -- **Status bar control**: Status bar hidden on the Clock tab (including full-screen mode) +- **Status bar control**: Status bar hidden on the Clock tab - **Safe area expansion**: Clock expands into tab bar area when hidden - **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap - **Orientation handling**: Full-screen mode works in both portrait and landscape -- **Keep awake functionality**: Optional screen wake lock to prevent device sleep in display mode -- **Battery optimization**: Wake lock automatically disabled when exiting display mode +- **Keep awake functionality**: Optional screen wake lock to prevent device sleep when the app is active +- **Battery optimization**: Wake lock automatically managed based on app state +- **Clock Integrations**: Optional mini-controls for active alarms and white noise playback directly on the clock face ### 4. Information Overlays - **Battery level display**: Real-time battery percentage with dynamic icon @@ -567,8 +568,8 @@ The following changes **automatically require** PRD updates: ### Clock Tab 1. **View time**: Real-time clock display 2. **Access settings**: Tap gear icon in navigation bar -3. **Enter display mode**: Long-press anywhere on clock (0.6 seconds) -4. **Exit display mode**: Long-press again to return to normal mode +3. **Automatic Full-Screen**: UI automatically hides after 5 seconds of inactivity +4. **Restore UI**: Tap anywhere to bring back the navigation and tab bar ### Settings 1. **Time format**: Toggle 24-hour, seconds, AM/PM display diff --git a/README.md b/README.md index 66a01b9..365408f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - 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 +- 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 **White Noise** - Multiple ambient categories and curated sound packs @@ -48,8 +50,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and - Snooze support via AlarmKit's countdown feature **Display Mode** -- Long-press to enter immersive display mode -- Auto-hides navigation and status bar +- Automatic full-screen display after 5 seconds of inactivity +- Tap anywhere to restore navigation and status bar - Optional wake-lock to keep the screen on ### What's New diff --git a/TheNoiseClock/App/ContentView.swift b/TheNoiseClock/App/ContentView.swift index a390f41..d1155e7 100644 --- a/TheNoiseClock/App/ContentView.swift +++ b/TheNoiseClock/App/ContentView.swift @@ -94,10 +94,10 @@ struct ContentView: View { .onChange(of: selectedTab) { oldValue, newValue in Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") if oldValue == .clock && newValue != .clock { - Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false") - // Safety net: also explicitly disable display mode when leaving clock tab + Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false") + // Safety net: also explicitly disable full-screen mode when leaving clock tab // The ClockView's toolbar modifier already responds to isOnClockTab changing - clockViewModel.setDisplayMode(false) + clockViewModel.setFullScreenMode(false) } } .accentColor(AppAccent.primary) diff --git a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift index 3646ea1..6a61e92 100644 --- a/TheNoiseClock/Features/Alarms/Services/AlarmService.swift +++ b/TheNoiseClock/Features/Alarms/Services/AlarmService.swift @@ -18,6 +18,9 @@ import Bedrock @Observable class AlarmService { + // MARK: - Singleton + static let shared = AlarmService() + // MARK: - Properties private(set) var alarms: [Alarm] = [] private var alarmLookup: [UUID: Int] = [:] @@ -37,6 +40,7 @@ class AlarmService { alarms.append(alarm) updateAlarmLookup() saveAlarms() + NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) } /// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService. @@ -49,6 +53,7 @@ class AlarmService { alarms[index] = alarm updateAlarmLookup() saveAlarms() + NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) } /// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService. @@ -57,6 +62,7 @@ class AlarmService { alarms.removeAll { $0.id == id } updateAlarmLookup() saveAlarms() + NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) } /// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService. @@ -65,6 +71,7 @@ class AlarmService { alarms[index].isEnabled.toggle() Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)") saveAlarms() + NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil) } func getAlarm(id: UUID) -> Alarm? { @@ -129,7 +136,11 @@ class AlarmService { } /// Get all enabled alarms (for rescheduling with AlarmKit) - func getEnabledAlarms() -> [Alarm] { + var enabledAlarms: [Alarm] { return alarms.filter { $0.isEnabled } } + + func getEnabledAlarms() -> [Alarm] { + return enabledAlarms + } } diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index 4312794..536e083 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -34,7 +34,7 @@ class AlarmViewModel { } // MARK: - Initialization - init(alarmService: AlarmService = AlarmService()) { + init(alarmService: AlarmService = AlarmService.shared) { self.alarmService = alarmService } diff --git a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift index e722f81..08c1605 100644 --- a/TheNoiseClock/Features/Alarms/Views/AlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/AlarmView.swift @@ -21,52 +21,53 @@ struct AlarmView: View { // MARK: - Body var body: some View { let isPad = UIDevice.current.userInterfaceIdiom == .pad - Group { - if viewModel.alarms.isEmpty { - VStack(spacing: Design.Spacing.large) { - if !isKeepAwakeEnabled { - AlarmLimitationsBanner() - } - - EmptyAlarmsView { - showAddAlarm = true - } - .contentShape(Rectangle()) - .onTapGesture { - showAddAlarm = true - } - } - .frame(maxWidth: Design.Size.maxContentWidthPortrait) - .frame(maxWidth: .infinity, alignment: .center) - } else { - List { - if !isKeepAwakeEnabled { - Section { + ZStack { + AppSurface.primary.ignoresSafeArea() + + Group { + if viewModel.alarms.isEmpty { + VStack(spacing: Design.Spacing.large) { + if !isKeepAwakeEnabled { AlarmLimitationsBanner() - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + } + + EmptyAlarmsView { + showAddAlarm = true + } + .contentShape(Rectangle()) + .onTapGesture { + showAddAlarm = true } } - - ForEach(viewModel.alarms) { alarm in - AlarmRowView( - alarm: alarm, - onToggle: { - Task { - await viewModel.toggleAlarm(id: alarm.id) - } - }, - onEdit: { - selectedAlarmForEdit = alarm + .frame(maxWidth: Design.Size.maxContentWidthPortrait) + .frame(maxWidth: .infinity, alignment: .center) + } else { + ScrollView { + VStack(spacing: Design.Spacing.medium) { + if !isKeepAwakeEnabled { + AlarmLimitationsBanner() } - ) + + ForEach(viewModel.alarms) { alarm in + AlarmRowView( + alarm: alarm, + onToggle: { + Task { + await viewModel.toggleAlarm(id: alarm.id) + } + }, + onEdit: { + selectedAlarmForEdit = alarm + } + ) + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.large) } - .onDelete(perform: deleteAlarm) + .frame(maxWidth: Design.Size.maxContentWidthPortrait) + .frame(maxWidth: .infinity, alignment: .center) } - .listStyle(.insetGrouped) - .frame(maxWidth: Design.Size.maxContentWidthPortrait) - .frame(maxWidth: .infinity, alignment: .center) } } .navigationTitle(isPad ? "" : "Alarms") diff --git a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift index e0ad475..9fcd0da 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/AlarmRowView.swift @@ -20,43 +20,50 @@ struct AlarmRowView: View { // MARK: - Body var body: some View { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text(alarm.formattedTime()) - .font(.headline) - .foregroundColor(AppTextColors.primary) - - Text(alarm.label) - .font(.subheadline) - .foregroundColor(AppTextColors.secondary) - - Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") - .font(.caption) - .foregroundColor(AppTextColors.secondary) - - if alarm.isEnabled && !isKeepAwakeEnabled { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption2) - .foregroundStyle(AppStatus.warning) - Text("Foreground only for full alarm sound") - .font(.caption2) - .foregroundStyle(AppTextColors.tertiary) + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(alarm.formattedTime()) + .font(.headline) + .foregroundColor(AppTextColors.primary) + + Text(alarm.label) + .font(.subheadline) + .foregroundColor(AppTextColors.secondary) + + Text("• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))") + .font(.caption) + .foregroundColor(AppTextColors.secondary) + + if alarm.isEnabled && !isKeepAwakeEnabled { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(AppStatus.warning) + Text("Foreground only for full alarm sound") + .font(.caption2) + .foregroundStyle(AppTextColors.tertiary) + } } } + + Spacer() + + SettingsToggle( + title: "", + subtitle: "", + isOn: Binding( + get: { alarm.isEnabled }, + set: { _ in onToggle() } + ), + accentColor: AppAccent.primary + ) + .labelsHidden() + } + .contentShape(Rectangle()) + .onTapGesture { + onEdit() } - - Spacer() - - Toggle("", isOn: Binding( - get: { alarm.isEnabled }, - set: { _ in onToggle() } - )) - .labelsHidden() - } - .contentShape(Rectangle()) - .onTapGesture { - onEdit() } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift index 05cc5f5..31edf4d 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/EmptyAlarmsView.swift @@ -16,19 +16,53 @@ struct EmptyAlarmsView: View { // MARK: - Body var body: some View { - VStack(spacing: Design.Spacing.medium) { - // Icon - Image(systemName: "alarm") - .font(.largeTitle) - .foregroundColor(.secondary) + VStack(spacing: Design.Spacing.large) { + Spacer() - // Instructional text - Text("Create an alarm to begin") - .font(.subheadline) - .foregroundColor(.secondary) + // Icon with subtle animation + ZStack { + Circle() + .fill(AppAccent.primary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "alarm.fill") + .font(.system(size: 40)) + .foregroundColor(AppAccent.primary) + .symbolEffect(.bounce, value: true) + } + + VStack(spacing: Design.Spacing.small) { + Text("No Alarms Set") + .typography(.title2Bold) + .foregroundColor(AppTextColors.primary) + + Text("Create an alarm to wake up gently on your own terms.") + .typography(.body) + .foregroundColor(AppTextColors.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Design.Spacing.xxLarge) + } + + // Primary Action Button + Button(action: onAddAlarm) { + HStack { + Image(systemName: "plus.circle.fill") + Text("Add Your First Alarm") + } + .typography(.bodyEmphasis) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background(AppAccent.primary) + .cornerRadius(Design.CornerRadius.medium) + .shadow(color: AppAccent.primary.opacity(0.3), radius: 8, x: 0, y: 4) + } + .padding(.top, Design.Spacing.medium) + + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.clear) + .background(AppSurface.primary) } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/LabelEditView.swift b/TheNoiseClock/Features/Alarms/Views/Components/LabelEditView.swift index c7b8092..213956c 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/LabelEditView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/LabelEditView.swift @@ -14,15 +14,22 @@ struct LabelEditView: View { @Environment(\.dismiss) private var dismiss var body: some View { - VStack(spacing: Design.Spacing.large) { - TextField("Alarm Label", text: $label) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .contentPadding(horizontal: Design.Spacing.large) - - Spacer() + Form { + Section { + TextField("Alarm Label", text: $label) + .typography(.body) + .foregroundStyle(AppTextColors.primary) + .padding(.vertical, Design.Spacing.small) + } footer: { + Text("Enter a name for your alarm.") + .typography(.caption) + .foregroundStyle(AppTextColors.secondary) + } + .listRowBackground(AppSurface.card) } + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) .navigationTitle("Label") .navigationBarTitleDisplayMode(.inline) - .contentPadding(vertical: Design.Spacing.large) } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/NotificationMessageEditView.swift b/TheNoiseClock/Features/Alarms/Views/Components/NotificationMessageEditView.swift index 6d3a74b..34291bb 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/NotificationMessageEditView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/NotificationMessageEditView.swift @@ -14,37 +14,24 @@ struct NotificationMessageEditView: View { @Environment(\.dismiss) private var dismiss var body: some View { - VStack(spacing: Design.Spacing.large) { - TextField("Notification message", text: $message) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .contentPadding(horizontal: Design.Spacing.large) - - // Preview section - VStack(alignment: .leading, spacing: Design.Spacing.small) { - Text("Preview:") - .font(.headline) - .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 4) { - Text("Alarm") - .font(.headline) - .foregroundColor(.primary) - - Text(message.isEmpty ? "Your alarm is ringing" : message) - .font(.body) - .foregroundColor(.secondary) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) + Form { + Section { + TextEditor(text: $message) + .frame(minHeight: 100) + .typography(.body) + .foregroundStyle(AppTextColors.primary) + .padding(.vertical, Design.Spacing.xxSmall) + } footer: { + Text("This message will appear when the alarm rings.") + .typography(.caption) + .foregroundStyle(AppTextColors.secondary) } - .contentPadding(horizontal: Design.Spacing.large) - - Spacer() + .listRowBackground(AppSurface.card) } + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) .navigationTitle("Message") .navigationBarTitleDisplayMode(.inline) - .contentPadding(vertical: Design.Spacing.large) } } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift index 85f590c..b9cfdf1 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/SnoozeSelectionView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Bedrock /// View for selecting snooze duration struct SnoozeSelectionView: View { @@ -20,6 +21,7 @@ struct SnoozeSelectionView: View { ForEach(snoozeOptions, id: \.self) { duration in HStack { Text("\(duration) minutes") + .foregroundColor(AppTextColors.primary) Spacer() if snoozeDuration == duration { Image(systemName: "checkmark") @@ -27,12 +29,16 @@ struct SnoozeSelectionView: View { } } .contentShape(Rectangle()) + .listRowBackground(AppSurface.card) .onTapGesture { snoozeDuration = duration } } } } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) .navigationTitle("Snooze") .navigationBarTitleDisplayMode(.inline) } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift b/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift index 1ad6901..1c5f2ff 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/SoundSelectionView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Bedrock import AudioPlaybackKit /// View for selecting alarm sounds with preview functionality @@ -27,6 +28,7 @@ struct SoundSelectionView: View { HStack { Text(sound.name) .font(.body) + .foregroundColor(AppTextColors.primary) Spacer() if selectedSound == sound.fileName { Image(systemName: "checkmark") @@ -34,6 +36,7 @@ struct SoundSelectionView: View { } } .contentShape(Rectangle()) + .listRowBackground(AppSurface.card) .onTapGesture { // Stop any currently playing sound when selecting a new one if isPlaying { @@ -44,6 +47,9 @@ struct SoundSelectionView: View { } } } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) .navigationTitle("Sound") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/TheNoiseClock/Features/Alarms/Views/Components/TimePickerSection.swift b/TheNoiseClock/Features/Alarms/Views/Components/TimePickerSection.swift index f6d3031..e816d4e 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/TimePickerSection.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/TimePickerSection.swift @@ -28,8 +28,9 @@ struct TimePickerSection: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } - .background(Color(.systemGroupedBackground)) + .background(AppSurface.primary) } + .frame(maxWidth: .infinity) .frame(height: 200) .onOrientationChange() // Force updates on orientation changes } diff --git a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift index c984fc1..f909a41 100644 --- a/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift +++ b/TheNoiseClock/Features/Alarms/Views/Components/TimeUntilAlarmSection.swift @@ -25,8 +25,9 @@ struct TimeUntilAlarmSection: View { .font(.caption) .foregroundColor(.secondary) } + .frame(maxWidth: .infinity) .padding(.vertical, 12) - .background(Color(.systemGroupedBackground)) + .background(AppSurface.primary) } private var timeUntilAlarm: String { diff --git a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift index 056c808..a87ad0a 100644 --- a/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift +++ b/TheNoiseClock/Features/Alarms/Views/EditAlarmView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Bedrock import AudioPlaybackKit import Foundation @@ -69,11 +70,13 @@ struct EditAlarmView: View { .foregroundColor(AppAccent.primary) .frame(width: 24) Text("Label") + .foregroundStyle(AppTextColors.primary) Spacer() Text(alarmLabel) - .foregroundColor(.secondary) + .foregroundColor(AppTextColors.secondary) } } + .listRowBackground(AppSurface.card) // Notification Message Section NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) { @@ -82,12 +85,14 @@ struct EditAlarmView: View { .foregroundColor(AppAccent.primary) .frame(width: 24) Text("Message") + .foregroundStyle(AppTextColors.primary) Spacer() Text(notificationMessage) - .foregroundColor(.secondary) + .foregroundColor(AppTextColors.secondary) .lineLimit(1) } } + .listRowBackground(AppSurface.card) // Sound Section NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) { @@ -96,11 +101,13 @@ struct EditAlarmView: View { .foregroundColor(AppAccent.primary) .frame(width: 24) Text("Sound") + .foregroundStyle(AppTextColors.primary) Spacer() Text(getSoundDisplayName(selectedSoundName)) - .foregroundColor(.secondary) + .foregroundColor(AppTextColors.secondary) } } + .listRowBackground(AppSurface.card) // Snooze Section NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { @@ -109,13 +116,17 @@ struct EditAlarmView: View { .foregroundColor(AppAccent.primary) .frame(width: 24) Text("Snooze") + .foregroundStyle(AppTextColors.primary) Spacer() Text("for \(snoozeDuration) min") - .foregroundColor(.secondary) + .foregroundColor(AppTextColors.secondary) } } + .listRowBackground(AppSurface.card) } .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(AppSurface.primary.ignoresSafeArea()) } .navigationTitle("Edit Alarm") .navigationBarTitleDisplayMode(.inline) diff --git a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift index 1bbe6a1..16002d3 100644 --- a/TheNoiseClock/Features/Clock/Models/ClockStyle.swift +++ b/TheNoiseClock/Features/Clock/Models/ClockStyle.swift @@ -46,6 +46,8 @@ class ClockStyle: Codable, Equatable { // MARK: - Overlay Settings var showBattery: Bool = true var showDate: Bool = true + var showNextAlarm: Bool = true + var showNoiseControls: Bool = true var dateFormat: String = "d MMMM EEE" // Default: "7 September Mon" var clockOpacity: Double = AppConstants.Defaults.clockOpacity var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity @@ -83,6 +85,8 @@ class ClockStyle: Codable, Equatable { case digitAnimationStyle case showBattery case showDate + case showNextAlarm + case showNoiseControls case dateFormat case clockOpacity case overlayOpacity @@ -135,6 +139,8 @@ class ClockStyle: Codable, Equatable { } self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate + self.showNextAlarm = try container.decodeIfPresent(Bool.self, forKey: .showNextAlarm) ?? self.showNextAlarm + self.showNoiseControls = try container.decodeIfPresent(Bool.self, forKey: .showNoiseControls) ?? self.showNoiseControls self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity @@ -169,6 +175,8 @@ class ClockStyle: Codable, Equatable { try container.encode(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle) try container.encode(showBattery, forKey: .showBattery) try container.encode(showDate, forKey: .showDate) + try container.encode(showNextAlarm, forKey: .showNextAlarm) + try container.encode(showNoiseControls, forKey: .showNoiseControls) try container.encode(dateFormat, forKey: .dateFormat) try container.encode(clockOpacity, forKey: .clockOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity) @@ -463,6 +471,8 @@ class ClockStyle: Codable, Equatable { lhs.digitAnimationStyle == rhs.digitAnimationStyle && lhs.showBattery == rhs.showBattery && lhs.showDate == rhs.showDate && + lhs.showNextAlarm == rhs.showNextAlarm && + lhs.showNoiseControls == rhs.showNoiseControls && lhs.dateFormat == rhs.dateFormat && lhs.clockOpacity == rhs.clockOpacity && lhs.overlayOpacity == rhs.overlayOpacity && diff --git a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift index 3948b7a..9451f06 100644 --- a/TheNoiseClock/Features/Clock/State/ClockViewModel.swift +++ b/TheNoiseClock/Features/Clock/State/ClockViewModel.swift @@ -19,7 +19,7 @@ class ClockViewModel { // MARK: - Properties private(set) var currentTime = Date() private(set) var style = ClockStyle() - private(set) var isDisplayMode = false + private(set) var isFullScreenMode = false // Wake lock service private let wakeLockService = WakeLockService.shared @@ -65,28 +65,28 @@ class ClockViewModel { } // MARK: - Public Interface - func toggleDisplayMode() { - let oldValue = isDisplayMode + func toggleFullScreenMode() { + let oldValue = isFullScreenMode withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { - isDisplayMode.toggle() + isFullScreenMode.toggle() } - Design.debugLog("[ClockViewModel] toggleDisplayMode: \(oldValue) -> \(isDisplayMode)") + Design.debugLog("[ClockViewModel] toggleFullScreenMode: \(oldValue) -> \(isFullScreenMode)") - // Manage wake lock based on display mode and keep awake setting + // Manage wake lock based on full-screen mode and keep awake setting updateWakeLockState() - if isDisplayMode { + if isFullScreenMode { requestKeepAwakePromptIfNeeded() } } - func setDisplayMode(_ enabled: Bool) { - guard isDisplayMode != enabled else { - Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping") + func setFullScreenMode(_ enabled: Bool) { + guard isFullScreenMode != enabled else { + Design.debugLog("[ClockViewModel] setFullScreenMode(\(enabled)) - already at this value, skipping") return } - Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)") + Design.debugLog("[ClockViewModel] setFullScreenMode: \(isFullScreenMode) -> \(enabled)") withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { - isDisplayMode = enabled + isFullScreenMode = enabled } updateWakeLockState() if enabled { @@ -110,6 +110,8 @@ class ClockViewModel { style.fontDesign = newStyle.fontDesign style.showBattery = newStyle.showBattery style.showDate = newStyle.showDate + style.showNextAlarm = newStyle.showNextAlarm + style.showNoiseControls = newStyle.showNoiseControls style.overlayOpacity = newStyle.overlayOpacity style.backgroundHex = newStyle.backgroundHex style.keepAwake = newStyle.keepAwake @@ -237,8 +239,8 @@ class ClockViewModel { /// Update wake lock state based on current settings private func updateWakeLockState() { - // Enable wake lock if in display mode and keep awake is enabled - if isDisplayMode && style.keepAwake { + // Enable wake lock if in full-screen mode and keep awake is enabled + if isFullScreenMode && style.keepAwake { wakeLockService.enableWakeLock() } else { wakeLockService.disableWakeLock() diff --git a/TheNoiseClock/Features/Clock/Views/ClockView.swift b/TheNoiseClock/Features/Clock/Views/ClockView.swift index d3ea80b..3736779 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockView.swift @@ -24,9 +24,9 @@ struct ClockView: View { /// Tab bar should ONLY be hidden when BOTH conditions are true: /// 1. We're on the clock tab (prevents hiding when user switches away) - /// 2. Display mode is active + /// 2. Full-screen mode is active private var shouldHideTabBar: Bool { - isOnClockTab && viewModel.isDisplayMode + isOnClockTab && viewModel.isFullScreenMode } // MARK: - Body @@ -54,7 +54,7 @@ struct ClockView: View { ClockDisplayContainer( currentTime: viewModel.currentTime, style: viewModel.style, - isDisplayMode: viewModel.isDisplayMode + isFullScreenMode: viewModel.isFullScreenMode ) .padding(.leading, symmetricInset) .padding(.trailing, symmetricInset) @@ -92,7 +92,7 @@ struct ClockView: View { // This prevents race conditions: when tab changes, isOnClockTab becomes false immediately .toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar) .onChange(of: shouldHideTabBar) { oldValue, newValue in - Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isDisplayMode=\(viewModel.isDisplayMode))") + Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isFullScreenMode=\(viewModel.isFullScreenMode))") } .simultaneousGesture( DragGesture(minimumDistance: 0) @@ -116,8 +116,8 @@ struct ClockView: View { idleTimer?.invalidate() idleTimer = nil } - .onChange(of: viewModel.isDisplayMode) { _, isDisplayMode in - if isDisplayMode { + .onChange(of: viewModel.isFullScreenMode) { _, isFullScreenMode in + if isFullScreenMode { idleTimer?.invalidate() idleTimer = nil } else { @@ -130,29 +130,29 @@ struct ClockView: View { private func resetIdleTimer() { idleTimer?.invalidate() idleTimer = nil - guard !viewModel.isDisplayMode else { return } + guard !viewModel.isFullScreenMode else { return } idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in - enterDisplayModeFromIdle() + enterFullScreenFromIdle() } } - private func enterDisplayModeFromIdle() { - // Guard against entering display mode if we're no longer on the clock tab + private func enterFullScreenFromIdle() { + // Guard against entering full-screen if we're no longer on the clock tab guard isViewActive else { - Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: view is not active (user switched tabs)") + Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: view is not active (user switched tabs)") return } - guard !viewModel.isDisplayMode else { - Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode") + guard !viewModel.isFullScreenMode else { + Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: already in full-screen") return } - Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode") - viewModel.toggleDisplayMode() + Design.debugLog("[ClockView] enterFullScreenFromIdle - entering full-screen") + viewModel.toggleFullScreenMode() } private func handleUserInteraction() { - if viewModel.isDisplayMode { - viewModel.toggleDisplayMode() + if viewModel.isFullScreenMode { + viewModel.toggleFullScreenMode() } resetIdleTimer() } diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift index 1417c4a..a62da51 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift @@ -14,13 +14,13 @@ struct ClockDisplayContainer: View { // MARK: - Properties let currentTime: Date let style: ClockStyle - let isDisplayMode: Bool + let isFullScreenMode: Bool // MARK: - Body var body: some View { GeometryReader { geometry in let isPortrait = geometry.size.height >= geometry.size.width - let hasOverlay = style.showBattery || style.showDate + let hasOverlay = style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0 // Time display - fills all available space @@ -36,13 +36,13 @@ struct ClockDisplayContainer: View { fontWeight: style.fontWeight, fontDesign: style.fontDesign, forceHorizontalMode: style.forceHorizontalMode, - isDisplayMode: isDisplayMode, + isDisplayMode: isFullScreenMode, animationStyle: style.digitAnimationStyle ) .padding(.top, topSpacing) .frame(width: geometry.size.width, height: geometry.size.height) .transition(.opacity) - .animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode) + .animation(.smooth(duration: Design.Animation.standard), value: isFullScreenMode) } } } @@ -52,7 +52,7 @@ struct ClockDisplayContainer: View { ClockDisplayContainer( currentTime: Date(), style: ClockStyle(), - isDisplayMode: false + isFullScreenMode: false ) .frame(width: 400, height: 600) .background(Color.black) diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockGestureHandler.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockGestureHandler.swift deleted file mode 100644 index 17f3df0..0000000 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockGestureHandler.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ClockGestureHandler.swift -// TheNoiseClock -// -// Created by Matt Bruce on 9/8/25. -// - -import SwiftUI - -/// Component that handles gesture interactions for the clock view -struct ClockGestureHandler: View { - - // MARK: - Properties - let onLongPress: () -> Void - - // MARK: - Body - var body: some View { - EmptyView() - .contentShape(Rectangle()) - .simultaneousGesture( - LongPressGesture(minimumDuration: AppConstants.DisplayMode.longPressDuration) - .onEnded { _ in - onLongPress() - } - ) - } -} - -// MARK: - Preview -#Preview { - ClockGestureHandler(onLongPress: {}) -} diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift index 89db81a..c625ccc 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockOverlayContainer.swift @@ -17,13 +17,15 @@ struct ClockOverlayContainer: View { // MARK: - Body var body: some View { VStack { - if style.showBattery || style.showDate { + if style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls { TopOverlayView( showBattery: style.showBattery, showDate: style.showDate, color: style.effectiveDigitColor, opacity: style.clockOpacity, - dateFormat: style.dateFormat + dateFormat: style.dateFormat, + showNextAlarm: style.showNextAlarm, + showNoiseControls: style.showNoiseControls ) .padding(.top, Design.Spacing.small) .padding(.horizontal, Design.Spacing.large) diff --git a/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift b/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift new file mode 100644 index 0000000..d47ff67 --- /dev/null +++ b/TheNoiseClock/Features/Clock/Views/Components/NextAlarmOverlay.swift @@ -0,0 +1,55 @@ +// +// NextAlarmOverlay.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/8/25. +// + +import SwiftUI +import Bedrock + +/// Component for displaying the next scheduled alarm on the clock face +struct NextAlarmOverlay: View { + + // MARK: - Properties + let alarmTime: Date? + let color: Color + let opacity: Double + + private var alarmString: String { + guard let time = alarmTime else { return "" } + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: time) + } + + // MARK: - Body + var body: some View { + Group { + if let _ = alarmTime { + HStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "alarm.fill") + .font(.caption) + + Text(alarmString) + .typography(.calloutEmphasis) + } + .foregroundColor(color) + .opacity(opacity) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xxSmall) + .background(AppSurface.overlay.opacity(0.3)) + .cornerRadius(Design.CornerRadius.small) + } + } + } +} + +#Preview { + NextAlarmOverlay( + alarmTime: Date().addingTimeInterval(3600), + color: .white, + opacity: 0.8 + ) + .background(Color.black) +} diff --git a/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift b/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift new file mode 100644 index 0000000..849bed6 --- /dev/null +++ b/TheNoiseClock/Features/Clock/Views/Components/NoiseMiniPlayer.swift @@ -0,0 +1,79 @@ +// +// NoiseMiniPlayer.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/8/25. +// + +import SwiftUI +import Bedrock +import AudioPlaybackKit + +/// Compact mini-player for white noise on the clock face +struct NoiseMiniPlayer: View { + + // MARK: - Properties + let isPlaying: Bool + let soundName: String? + let color: Color + let opacity: Double + let onToggle: () -> Void + + // MARK: - Body + var body: some View { + Group { + if let name = soundName { + HStack(spacing: Design.Spacing.small) { + Button(action: onToggle) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(isPlaying ? AppAccent.primary.opacity(0.8) : 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)) + .textCase(.uppercase) + + Text(name) + .typography(.calloutEmphasis) + .foregroundColor(color) + .lineLimit(1) + } + } + .padding(.leading, Design.Spacing.xxSmall) + .padding(.trailing, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xxSmall) + .background(AppSurface.overlay.opacity(0.3)) + .cornerRadius(Design.CornerRadius.appLarge) + .opacity(opacity) + } + } + } +} + +#Preview { + VStack(spacing: 20) { + NoiseMiniPlayer( + isPlaying: true, + soundName: "Heavy Rain", + color: .white, + opacity: 0.9, + onToggle: {} + ) + + NoiseMiniPlayer( + isPlaying: false, + soundName: "White Noise", + color: .white, + opacity: 0.9, + onToggle: {} + ) + } + .padding() + .background(Color.black) +} diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift index 59af5d6..7054438 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedAppearanceSection.swift @@ -20,47 +20,62 @@ struct AdvancedAppearanceSection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text("Digit Animation") - .font(.subheadline) - .foregroundStyle(AppTextColors.secondary) - - Picker("Digit Animation", selection: $style.digitAnimationStyle) { - ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in - Text(animation.displayName).tag(animation) - } + VStack(spacing: 0) { + SettingsNavigationRow( + title: "Digit Animation", + subtitle: style.digitAnimationStyle.displayName, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.digitAnimationStyle, + options: DigitAnimationStyle.allCases, + title: "Digit Animation", + toString: { $0.displayName } + ) } - .pickerStyle(.menu) - .tint(AppAccent.primary) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Randomize Color", + subtitle: "Shift the color every minute", + isOn: $style.randomizeColor, + accentColor: AppAccent.primary + ) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsSlider( + title: "Glow", + subtitle: "Adjust the glow intensity", + value: $style.glowIntensity, + in: 0.0...1.0, + step: 0.01, + format: SliderFormat.percentage, + accentColor: AppAccent.primary + ) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsSlider( + title: "Clock Opacity", + subtitle: "Set the clock transparency", + value: $style.clockOpacity, + in: 0.0...1.0, + step: 0.01, + format: SliderFormat.percentage, + accentColor: AppAccent.primary + ) } - .padding(.vertical, Design.Spacing.xSmall) - - SettingsToggle( - title: "Randomize Color", - subtitle: "Shift the color every minute", - isOn: $style.randomizeColor, - accentColor: AppAccent.primary - ) - - SettingsSlider( - title: "Glow", - subtitle: "Adjust the glow intensity", - value: $style.glowIntensity, - in: 0.0...1.0, - step: 0.01, - format: SliderFormat.percentage, - accentColor: AppAccent.primary - ) - - SettingsSlider( - title: "Clock Opacity", - subtitle: "Set the clock transparency", - value: $style.clockOpacity, - in: 0.0...1.0, - step: 0.01, - format: SliderFormat.percentage, - accentColor: AppAccent.primary - ) } Text("Fine-tune the visual appearance of your clock.") diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift index 78fcd29..8feaca3 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/AdvancedDisplaySection.swift @@ -20,29 +20,43 @@ struct AdvancedDisplaySection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - SettingsToggle( - title: "Keep Awake", - subtitle: "Prevent sleep in display mode", - isOn: $style.keepAwake, - accentColor: AppAccent.primary - ) + VStack(spacing: 0) { + SettingsToggle( + title: "Keep Awake", + subtitle: "Prevent sleep in display mode", + isOn: $style.keepAwake, + accentColor: AppAccent.primary + ) - SettingsToggle( - title: "Live Activities", - subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing", - isOn: $style.liveActivitiesEnabled, - accentColor: AppAccent.primary - ) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - if style.autoBrightness { - HStack { - Text("Current Brightness") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) - Spacer() - Text("\(Int(style.effectiveBrightness * 100))%") - .font(.subheadline) - .foregroundStyle(AppTextColors.secondary) + SettingsToggle( + title: "Live Activities", + subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing", + isOn: $style.liveActivitiesEnabled, + accentColor: AppAccent.primary + ) + + if style.autoBrightness { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + HStack { + Text("Current Brightness") + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppTextColors.primary) + Spacer() + Text("\(Int(style.effectiveBrightness * 100))%") + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + .padding(.vertical, Design.Spacing.medium) + .padding(.horizontal, Design.Spacing.medium) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicAppearanceSection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicAppearanceSection.swift index 75725ff..fbc1371 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicAppearanceSection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicAppearanceSection.swift @@ -22,38 +22,49 @@ struct BasicAppearanceSection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Color Theme") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) - - Picker("Color Theme", selection: $style.selectedColorTheme) { - ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in - HStack { - Circle() - .fill(themeColor(for: theme.0)) - .frame(width: 20, height: 20) - Text(theme.1) - } - .tag(theme.0) - } - } - .pickerStyle(.menu) - .onChange(of: style.selectedColorTheme) { _, newTheme in - if newTheme != "Custom" { - style.applyColorTheme(newTheme) - digitColor = Color(hex: style.digitColorHex) ?? .white - backgroundColor = Color(hex: style.backgroundHex) ?? .black + VStack(spacing: 0) { + SettingsNavigationRow( + title: "Color Theme", + subtitle: style.selectedColorTheme, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.selectedColorTheme, + options: ClockStyle.availableColorThemes().map { $0.0 }, + title: "Color Theme", + toString: { theme in + ClockStyle.availableColorThemes().first(where: { $0.0 == theme })?.1 ?? theme } + ) + } + .onChange(of: style.selectedColorTheme) { _, newTheme in + if newTheme != "Custom" { + style.applyColorTheme(newTheme) + digitColor = Color(hex: style.digitColorHex) ?? .white + backgroundColor = Color(hex: style.backgroundHex) ?? .black } } if style.selectedColorTheme == "Custom" { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false) .foregroundStyle(AppTextColors.primary) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true) .foregroundStyle(AppTextColors.primary) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicDisplaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicDisplaySection.swift index b55c379..7c083a2 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicDisplaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/BasicDisplaySection.swift @@ -20,43 +20,65 @@ struct BasicDisplaySection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - SettingsToggle( - title: "24‑Hour Format", - subtitle: "Use military time", - isOn: $style.use24Hour, - accentColor: AppAccent.primary - ) - - SettingsToggle( - title: "Show Seconds", - subtitle: "Display seconds in the clock", - isOn: $style.showSeconds, - accentColor: AppAccent.primary - ) - - if !style.use24Hour { + VStack(spacing: 0) { SettingsToggle( - title: "Show AM/PM", - subtitle: "Add an AM/PM indicator", - isOn: $style.showAmPm, + title: "24‑Hour Format", + subtitle: "Use military time", + isOn: $style.use24Hour, accentColor: AppAccent.primary ) - } - SettingsToggle( - title: "Auto Brightness", - subtitle: "Adapt brightness to ambient light", - isOn: $style.autoBrightness, - accentColor: AppAccent.primary - ) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown { SettingsToggle( - title: "Horizontal Mode", - subtitle: "Force a wide layout in portrait", - isOn: $style.forceHorizontalMode, + title: "Show Seconds", + subtitle: "Display seconds in the clock", + isOn: $style.showSeconds, accentColor: AppAccent.primary ) + + if !style.use24Hour { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Show AM/PM", + subtitle: "Add an AM/PM indicator", + isOn: $style.showAmPm, + accentColor: AppAccent.primary + ) + } + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Auto Brightness", + subtitle: "Adapt brightness to ambient light", + isOn: $style.autoBrightness, + accentColor: AppAccent.primary + ) + + if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Horizontal Mode", + subtitle: "Force a wide layout in portrait", + isOn: $style.forceHorizontalMode, + accentColor: AppAccent.primary + ) + } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/FontSection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/FontSection.swift index 1d95e22..3ab76fa 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/FontSection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/FontSection.swift @@ -37,58 +37,76 @@ struct FontSection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - VStack(alignment: .leading, spacing: Design.Spacing.medium) { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Family") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) - - Picker("Family", selection: $style.fontFamily) { - ForEach(sortedFontFamilies, id: \.self) { family in - Text(family.rawValue).tag(family) - } + VStack(alignment: .leading, spacing: 0) { + // Font Family + SettingsNavigationRow( + title: "Family", + subtitle: style.fontFamily.rawValue, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.fontFamily, + options: sortedFontFamilies, + title: "Font Family", + toString: { $0.rawValue } + ) + } + .onChange(of: style.fontFamily) { _, newFamily in + if newFamily != .system { + style.fontDesign = .default } - .pickerStyle(.menu) - .onChange(of: style.fontFamily) { _, newFamily in - if newFamily != .system { - style.fontDesign = .default - } - let weights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights - if !weights.contains(style.fontWeight) { - style.fontWeight = weights.first ?? .regular - } + let weights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights + if !weights.contains(style.fontWeight) { + style.fontWeight = weights.first ?? .regular } } - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Weight") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - Picker("Weight", selection: $style.fontWeight) { - ForEach(availableWeights, id: \.self) { weight in - Text(weight.rawValue).tag(weight) - } - } - .pickerStyle(.menu) + // Font Weight + SettingsNavigationRow( + title: "Weight", + subtitle: style.fontWeight.rawValue, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.fontWeight, + options: availableWeights, + title: "Font Weight", + toString: { $0.rawValue } + ) } if style.fontFamily == .system { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Design") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - Picker("Design", selection: $style.fontDesign) { - ForEach(Font.Design.allCases, id: \.self) { design in - Text(design.rawValue).tag(design) - } - } - .pickerStyle(.menu) + // Font Design + SettingsNavigationRow( + title: "Design", + subtitle: style.fontDesign.rawValue, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.fontDesign, + options: Font.Design.allCases, + title: "Font Design", + toString: { $0.rawValue } + ) } } + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + HStack { Text("Preview") .font(.subheadline.weight(.medium)) @@ -98,6 +116,7 @@ struct FontSection: View { .font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24)) .foregroundStyle(AppTextColors.primary) } + .padding(Design.Spacing.medium) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/NightModeSection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/NightModeSection.swift index 86b8f23..6731e8f 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/NightModeSection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/NightModeSection.swift @@ -20,65 +20,103 @@ struct NightModeSection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - SettingsToggle( - title: "Enable Night Mode", - subtitle: "Use a red clock for low light", - isOn: $style.nightModeEnabled, - accentColor: AppAccent.primary - ) - - SettingsToggle( - title: "Auto Night Mode", - subtitle: "Trigger based on ambient light", - isOn: $style.autoNightMode, - accentColor: AppAccent.primary - ) - - if style.autoNightMode { - SettingsSlider( - title: "Light Threshold", - subtitle: "Lower values activate sooner", - value: $style.ambientLightThreshold, - in: 0.1...0.8, - step: 0.01, - format: SliderFormat.percentage, + VStack(spacing: 0) { + SettingsToggle( + title: "Enable Night Mode", + subtitle: "Use a red clock for low light", + isOn: $style.nightModeEnabled, accentColor: AppAccent.primary ) - } - SettingsToggle( - title: "Scheduled Night Mode", - subtitle: "Enable on a daily schedule", - isOn: $style.scheduledNightMode, - accentColor: AppAccent.primary - ) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - if style.scheduledNightMode { - HStack { - Text("Start Time") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) - Spacer() - TimePickerView(timeString: $style.nightModeStartTime) + SettingsToggle( + title: "Auto Night Mode", + subtitle: "Trigger based on ambient light", + isOn: $style.autoNightMode, + accentColor: AppAccent.primary + ) + + if style.autoNightMode { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsSlider( + title: "Light Threshold", + subtitle: "Lower values activate sooner", + value: $style.ambientLightThreshold, + in: 0.1...0.8, + step: 0.01, + format: SliderFormat.percentage, + accentColor: AppAccent.primary + ) } - HStack { - Text("End Time") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) - Spacer() - TimePickerView(timeString: $style.nightModeEndTime) - } - } + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - if style.isNightModeActive { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "moon.fill") - .foregroundStyle(AppStatus.error) - Text("Night Mode Active") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppStatus.error) - Spacer() + SettingsToggle( + title: "Scheduled Night Mode", + subtitle: "Enable on a daily schedule", + isOn: $style.scheduledNightMode, + accentColor: AppAccent.primary + ) + + if style.scheduledNightMode { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + HStack { + Text("Start Time") + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppTextColors.primary) + Spacer() + TimePickerView(timeString: $style.nightModeStartTime) + } + .padding(.vertical, Design.Spacing.medium) + .padding(.horizontal, Design.Spacing.medium) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + HStack { + Text("End Time") + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppTextColors.primary) + Spacer() + TimePickerView(timeString: $style.nightModeEndTime) + } + .padding(.vertical, Design.Spacing.medium) + .padding(.horizontal, Design.Spacing.medium) + } + + if style.isNightModeActive { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "moon.fill") + .foregroundStyle(AppStatus.error) + Text("Night Mode Active") + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppStatus.error) + Spacer() + } + .padding(.vertical, Design.Spacing.small) + .padding(.horizontal, Design.Spacing.medium) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift b/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift index 0016a92..343c495 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/Settings/OverlaySection.swift @@ -22,32 +22,70 @@ struct OverlaySection: View { ) SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { - SettingsToggle( - title: "Battery Level", - subtitle: "Show battery percentage", - isOn: $style.showBattery, - accentColor: AppAccent.primary - ) + VStack(spacing: 0) { + SettingsToggle( + title: "Battery Level", + subtitle: "Show battery percentage", + isOn: $style.showBattery, + accentColor: AppAccent.primary + ) - SettingsToggle( - title: "Date", - subtitle: "Display the current date", - isOn: $style.showDate, - accentColor: AppAccent.primary - ) + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) - if style.showDate { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Date Format") - .font(.subheadline.weight(.medium)) - .foregroundStyle(AppTextColors.primary) + SettingsToggle( + title: "Date", + subtitle: "Display the current date", + isOn: $style.showDate, + accentColor: AppAccent.primary + ) - Picker("Date Format", selection: $style.dateFormat) { - ForEach(dateFormats, id: \.1) { format in - Text(format.0).tag(format.1) - } + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Next Alarm", + subtitle: "Show your next scheduled alarm", + isOn: $style.showNextAlarm, + accentColor: AppAccent.primary + ) + + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsToggle( + title: "Noise Controls", + subtitle: "Mini-player for white noise", + isOn: $style.showNoiseControls, + accentColor: AppAccent.primary + ) + + if style.showDate { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: 1) + .padding(.horizontal, Design.Spacing.medium) + + SettingsNavigationRow( + title: "Date Format", + subtitle: style.dateFormat, + backgroundColor: .clear + ) { + SettingsSelectionView( + selection: $style.dateFormat, + options: dateFormats.map { $0.1 }, + title: "Date Format", + toString: { format in + dateFormats.first(where: { $0.1 == format })?.0 ?? format + } + ) } - .pickerStyle(.menu) } } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift index 25d032e..c619112 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TopOverlayView.swift @@ -7,6 +7,7 @@ import SwiftUI import Bedrock +import AudioPlaybackKit /// Component for displaying top overlay with battery and date information struct TopOverlayView: View { @@ -17,30 +18,76 @@ struct TopOverlayView: View { let color: Color let opacity: Double let dateFormat: String + let showNextAlarm: Bool + let showNoiseControls: Bool - @State private var batteryService = BatteryService.shared + private var batteryService: BatteryService { .shared } + private var alarmService: AlarmService { .shared } + private var soundPlayer: SoundPlayer { .shared } + + @State private var clockUpdateTrigger = false // MARK: - Body var body: some View { - HStack { - if showDate { - DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat) + let _ = clockUpdateTrigger // Force re-render on style or alarm changes + let _ = alarmService.alarms // Observe all alarms for changes + let _ = soundPlayer.isPlaying // Observe player state + + let _ = print("TopOverlayView: Rendering. Alarms count: \(alarmService.alarms.count), Enabled: \(alarmService.enabledAlarms.count)") + + VStack(spacing: Design.Spacing.small) { + HStack { + if showDate { + DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat) + } + + Spacer() + + if showBattery { + BatteryOverlayView( + color: color, + opacity: opacity, + batteryLevel: batteryService.batteryLevel, + isCharging: batteryService.isCharging + ) + } } - Spacer() - - if showBattery { - BatteryOverlayView( - color: color, - opacity: opacity, - batteryLevel: batteryService.batteryLevel, - isCharging: batteryService.isCharging - ) + HStack(alignment: .top) { + if showNextAlarm, let nextAlarm = alarmService.enabledAlarms.sorted(by: { $0.time.nextOccurrence() < $1.time.nextOccurrence() }).first { + NextAlarmOverlay( + alarmTime: nextAlarm.time.nextOccurrence(), + color: color, + opacity: opacity + ) + } + + Spacer() + + if showNoiseControls, let sound = soundPlayer.currentSound { + NoiseMiniPlayer( + isPlaying: soundPlayer.isPlaying, + soundName: sound.name, + color: color, + opacity: opacity, + onToggle: { + if soundPlayer.isPlaying { + soundPlayer.stopSound() + } else { + soundPlayer.playSound(sound) + } + } + ) + } } } .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) .transition(.opacity) + .id(clockUpdateTrigger) // Force re-render on style or alarm changes + .onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in + clockUpdateTrigger.toggle() + } .onAppear { batteryService.startMonitoring() } diff --git a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift index 4371df3..f3ac272 100644 --- a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift +++ b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift @@ -98,7 +98,7 @@ struct SoundCategoryView: View { } private var soundGrid: some View { - LazyVStack(spacing: Design.Spacing.small) { + LazyVStack(spacing: Design.Spacing.medium) { ForEach(filteredSounds) { sound in SoundCard( sound: sound, @@ -114,7 +114,8 @@ struct SoundCategoryView: View { ) } } - .padding(.horizontal, Design.Spacing.medium) + .padding(.horizontal, Design.Spacing.large) + .padding(.bottom, Design.Spacing.xxLarge) } } @@ -129,19 +130,26 @@ struct CategoryTab: View { Button(action: action) { HStack(spacing: 4) { Text(title) - .font(.subheadline.weight(.medium)) + .styled(.subheadingEmphasis) if count > 0 { - Text("(\(count))") - .font(.caption) - .foregroundColor(.secondary) + Text("\(count)") + .styled(.caption, emphasis: .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isSelected ? Color.white.opacity(0.2) : AppSurface.secondary) + .clipShape(Capsule()) } } .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) - .background(isSelected ? AppAccent.primary : Color(.systemGray6)) + .background(isSelected ? AppAccent.primary : AppSurface.card) .foregroundColor(isSelected ? .white : AppTextColors.primary) - .cornerRadius(20) + .cornerRadius(Design.CornerRadius.medium) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .stroke(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: 1) + ) } .buttonStyle(.plain) } @@ -155,68 +163,45 @@ struct SoundCard: View { let onPreview: () -> Void var body: some View { - HStack(spacing: Design.Spacing.medium) { - // Sound Icon (Left) - ZStack { - Circle() - .fill(isSelected ? AppAccent.primary : Color(.systemGray5)) - .frame(width: 50, height: 50) - - Image(systemName: soundIcon) - .font(.title3) - .foregroundColor(isSelected ? .white : AppTextColors.primary) - - if isPreviewing { - Circle() - .stroke(AppAccent.primary, lineWidth: 2) - .frame(width: 58, height: 58) - .scaleEffect(1.02) - .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing) - } - } - - // Sound Info (Right) - VStack(alignment: .leading, spacing: 4) { - // Sound Name - Text(sound.name) - .font(.subheadline.weight(.medium)) - .foregroundColor(AppTextColors.primary) - .lineLimit(1) - - // Description - Text(sound.description) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - - // Category Badge - HStack { - Text(sound.category.capitalized) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(AppAccent.primary, in: Capsule()) + Button(action: onSelect) { + SettingsCard( + backgroundColor: isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card, + borderColor: isSelected ? AppAccent.primary : AppBorder.subtle + ) { + HStack(spacing: Design.Spacing.medium) { + // Sound Icon (Left) + ZStack { + Image(systemName: soundIcon) + .font(.title3) + .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) + + if isPreviewing { + Circle() + .stroke(AppAccent.primary, lineWidth: 2) + .frame(width: 40, height: 40) + .scaleEffect(1.05) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing) + } + } + .frame(width: 48, height: 48) + + // Sound Info (Center) + VStack(alignment: .leading, spacing: 2) { + Text(sound.name) + .styled(.subheadingEmphasis) + .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary) + .lineLimit(1) + + Text(sound.description) + .styled(.caption, emphasis: .secondary) + .lineLimit(2) + } Spacer() } } - - Spacer() - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? AppAccent.primary.opacity(0.1) : Color(.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? AppAccent.primary : Color.clear, lineWidth: 2) - ) - ) - .onTapGesture { - onSelect() } + .buttonStyle(.plain) .onLongPressGesture { onPreview() } diff --git a/TheNoiseClock/Features/Noise/Views/NoiseView.swift b/TheNoiseClock/Features/Noise/Views/NoiseView.swift index ee22fbb..673879f 100644 --- a/TheNoiseClock/Features/Noise/Views/NoiseView.swift +++ b/TheNoiseClock/Features/Noise/Views/NoiseView.swift @@ -30,21 +30,25 @@ struct NoiseView: View { // MARK: - Body var body: some View { - GeometryReader { geometry in - let isLandscape = geometry.size.width > geometry.size.height - let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait + ZStack { + AppSurface.primary.ignoresSafeArea() - Group { - if isLandscape { - // Landscape layout: Player on left, sounds on right - landscapeLayout - } else { - // Portrait layout: Stacked vertically - portraitLayout + GeometryReader { geometry in + let isLandscape = geometry.size.width > geometry.size.height + let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait + + Group { + if isLandscape { + // Landscape layout: Player on left, sounds on right + landscapeLayout + } else { + // Portrait layout: Stacked vertically + portraitLayout + } } + .frame(maxWidth: maxWidth) + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: maxWidth) - .frame(maxWidth: .infinity, alignment: .center) } .animation(.easeInOut(duration: 0.3), value: selectedSound) .searchable( @@ -79,23 +83,42 @@ struct NoiseView: View { soundControlView .centered() } else { - // Placeholder when no sound selected - VStack(spacing: Design.Spacing.small) { - Image(systemName: "music.note") - .font(.largeTitle) - .foregroundColor(.secondary) + // Placeholder when no sound selected - Enhanced for CRO + VStack(spacing: Design.Spacing.medium) { + ZStack { + Circle() + .fill(AppAccent.primary.opacity(0.1)) + .frame(width: 80, height: 80) + + Image(systemName: "waveform") + .font(.title) + .foregroundColor(AppAccent.primary) + .symbolEffect(.variableColor.iterative, options: .repeating) + } - Text("Select a sound to begin") - .font(.subheadline) - .foregroundColor(.secondary) + VStack(spacing: 4) { + Text("Ready for Sleep?") + .typography(.title3Bold) + .foregroundColor(AppTextColors.primary) + + Text("Select a soothing sound below to begin your relaxation journey.") + .typography(.caption) + .foregroundColor(AppTextColors.secondary) + .multilineTextAlignment(.center) + } } .frame(maxWidth: .infinity) - .padding(.vertical, Design.Spacing.large) + .padding(.vertical, Design.Spacing.xLarge) + .background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge) + .stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin) + ) } } .contentPadding(horizontal: Design.Spacing.large) .padding(.top, Design.Spacing.large) - .background(Color(.systemBackground)) + .background(AppSurface.primary) // Scrollable sound selection ScrollView { @@ -106,6 +129,7 @@ struct NoiseView: View { ) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) } + .scrollContentBackground(.hidden) } } @@ -121,18 +145,37 @@ struct NoiseView: View { if selectedSound != nil { soundControlView } else { - // Placeholder when no sound selected - VStack(spacing: Design.Spacing.small) { - Image(systemName: "music.note") - .font(.largeTitle) - .foregroundColor(.secondary) + // Placeholder when no sound selected - Enhanced for CRO + VStack(spacing: Design.Spacing.medium) { + ZStack { + Circle() + .fill(AppAccent.primary.opacity(0.1)) + .frame(width: 80, height: 80) + + Image(systemName: "waveform") + .font(.title) + .foregroundColor(AppAccent.primary) + .symbolEffect(.variableColor.iterative, options: .repeating) + } - Text("Select a sound to begin") - .font(.subheadline) - .foregroundColor(.secondary) + VStack(spacing: 4) { + Text("Ready for Sleep?") + .typography(.title3Bold) + .foregroundColor(AppTextColors.primary) + + Text("Select a soothing sound to begin.") + .typography(.caption) + .foregroundColor(AppTextColors.secondary) + .multilineTextAlignment(.center) + } } .frame(maxWidth: .infinity) - .padding(.vertical, Design.Spacing.large) + .padding(.vertical, Design.Spacing.xLarge) + .background(AppSurface.overlay, in: RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge)) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.appLarge) + .stroke(AppBorder.subtle, lineWidth: Design.LineWidth.thin) + ) } Spacer() @@ -151,6 +194,7 @@ struct NoiseView: View { ) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) } + .scrollContentBackground(.hidden) } } .contentPadding(horizontal: Design.Spacing.medium) diff --git a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift index d9d36e8..51d6543 100644 --- a/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift +++ b/TheNoiseClock/Features/Onboarding/Views/OnboardingView.swift @@ -26,7 +26,7 @@ struct OnboardingView: View { @State private var keepAwakeEnabled = false @State private var showCelebration = false - private let totalPages = 3 + private let totalPages = 4 // MARK: - Body @@ -42,11 +42,14 @@ struct OnboardingView: View { welcomeWithClockPage .tag(0) - permissionsPage + whiteNoisePage .tag(1) - getStartedPage + permissionsPage .tag(2) + + getStartedPage + .tag(3) } .tabViewStyle(.page(indexDisplayMode: .never)) .animation(.easeInOut(duration: 0.3), value: currentPage) @@ -71,8 +74,10 @@ struct OnboardingView: View { Spacer() // Live clock preview - immediate value using TimelineView - liveClockPreview - .padding(.bottom, Design.Spacing.medium) + TimelineView(.periodic(from: .now, by: 1.0)) { context in + OnboardingClockText(date: context.date) + } + .padding(.bottom, Design.Spacing.medium) Text("The Noise Clock") .typography(.heroBold) @@ -93,8 +98,8 @@ struct OnboardingView: View { text: "Wake up gently, on your terms" ) featureHighlight( - icon: "hand.tap.fill", - text: "Long-press for immersive mode" + icon: "clock.fill", + text: "Automatic full-screen display" ) } .padding(.top, Design.Spacing.large) @@ -104,12 +109,6 @@ struct OnboardingView: View { .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) @@ -126,7 +125,18 @@ struct OnboardingView: View { .padding(.horizontal, Design.Spacing.xxLarge) } - // MARK: - Page 2: AlarmKit Permissions + // 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) { @@ -255,7 +265,7 @@ struct OnboardingView: View { } } - // MARK: - Page 3: Get Started (Quick Win) + // MARK: - Page 4: Get Started (Quick Win) private var getStartedPage: some View { VStack(spacing: Design.Spacing.xxLarge) { @@ -276,7 +286,7 @@ struct OnboardingView: View { .typography(.heroBold) .foregroundStyle(AppTextColors.primary) - Text("Your alarms will work even in silent mode and Focus mode. Try long-pressing the clock for immersive mode!") + 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) @@ -285,7 +295,7 @@ struct OnboardingView: View { // Quick tips VStack(alignment: .leading, spacing: Design.Spacing.small) { tipRow(icon: "alarm.fill", text: "Create your first alarm") - tipRow(icon: "hand.tap", text: "Long-press clock for full screen") + 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) @@ -402,7 +412,7 @@ struct OnboardingView: View { if granted { try? await Task.sleep(for: .milliseconds(800)) withAnimation { - currentPage = 2 + currentPage = 3 } } } diff --git a/TheNoiseClock/Shared/Components/SettingsSelectionView.swift b/TheNoiseClock/Shared/Components/SettingsSelectionView.swift new file mode 100644 index 0000000..925df77 --- /dev/null +++ b/TheNoiseClock/Shared/Components/SettingsSelectionView.swift @@ -0,0 +1,83 @@ +// +// SettingsSelectionView.swift +// TheNoiseClock +// +// Created by Matt Bruce on 9/8/25. +// + +import SwiftUI +import Bedrock + +/// A reusable selection view for settings that navigates to a new screen. +struct SettingsSelectionView: View { + @Binding var selection: T + let options: [T] + let title: String + let toString: (T) -> String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + AppSurface.primary.ignoresSafeArea() + + ScrollView { + VStack(spacing: Design.Spacing.medium) { + SettingsSectionHeader( + title: title, + systemImage: "checklist", + accentColor: AppAccent.primary + ) + + SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + VStack(spacing: 0) { + ForEach(options, id: \.self) { option in + Button(action: { + selection = option + dismiss() + }) { + HStack { + Text(toString(option)) + .typography(.body) + .foregroundColor(AppTextColors.primary) + Spacer() + if selection == option { + Image(systemName: "checkmark") + .foregroundColor(AppAccent.primary) + .font(.body.bold()) + } + } + .padding(Design.Spacing.medium) + .background(Color.clear) + } + .buttonStyle(.plain) + + if option != options.last { + Divider() + .background(AppBorder.subtle) + .padding(.horizontal, Design.Spacing.medium) + } + } + } + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.large) + .padding(.bottom, Design.Spacing.xxxLarge) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + SettingsSelectionView( + selection: .constant("Option 1"), + options: ["Option 1", "Option 2", "Option 3"], + title: "Test Selection", + toString: { $0 } + ) + } +}