Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-02 22:13:06 -06:00
parent 3844e19b39
commit e959060af5
37 changed files with 1087 additions and 562 deletions

View File

@ -19,7 +19,7 @@ public class SoundPlayer {
// MARK: - Properties // MARK: - Properties
private var players: [String: AVAudioPlayer] = [:] private var players: [String: AVAudioPlayer] = [:]
private var currentPlayer: AVAudioPlayer? private var currentPlayer: AVAudioPlayer?
private var currentSound: Sound? public private(set) var currentSound: Sound?
private var shouldResumeAfterInterruption = false private var shouldResumeAfterInterruption = false
private let wakeLockService = WakeLockService.shared private let wakeLockService = WakeLockService.shared
private let soundConfigurationService = SoundConfigurationService.shared private let soundConfigurationService = SoundConfigurationService.shared
@ -102,13 +102,17 @@ public class SoundPlayer {
public func stopSound() { public func stopSound() {
currentPlayer?.stop() currentPlayer?.stop()
currentPlayer = nil currentPlayer = nil
currentSound = nil
shouldResumeAfterInterruption = false shouldResumeAfterInterruption = false
// Disable wake lock when stopping audio // Disable wake lock when stopping audio
wakeLockService.disableWakeLock() wakeLockService.disableWakeLock()
} }
public func clearCurrentSound() {
stopSound()
currentSound = nil
}
// MARK: - Private Methods // MARK: - Private Methods
/// Helper method to get URL for sound file, handling bundles and direct paths /// Helper method to get URL for sound file, handling bundles and direct paths

View File

@ -19,6 +19,8 @@ public class SoundViewModel {
public var isPreviewing: Bool = false public var isPreviewing: Bool = false
public var previewSound: Sound? public var previewSound: Sound?
public var selectedSound: Sound?
public var isPlaying: Bool { public var isPlaying: Bool {
soundPlayer.isPlaying soundPlayer.isPlaying
} }
@ -49,6 +51,8 @@ public class SoundViewModel {
} }
// Stop any preview // Stop any preview
stopPreview() stopPreview()
selectedSound = sound
} }
// MARK: - Preview Functionality // MARK: - Preview Functionality

15
PRD.md
View File

@ -47,17 +47,18 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
### 3. Display Modes ### 3. Display Modes
- **Normal mode**: Standard interface with navigation and settings - **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 automatically hide after 5 seconds of inactivity on the Clock tab
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode - **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 - **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) - **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
- **Smooth transitions**: Animated transitions between modes - **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 - **Safe area expansion**: Clock expands into tab bar area when hidden
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap - **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
- **Orientation handling**: Full-screen mode works in both portrait and landscape - **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 - **Keep awake functionality**: Optional screen wake lock to prevent device sleep when the app is active
- **Battery optimization**: Wake lock automatically disabled when exiting display mode - **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 ### 4. Information Overlays
- **Battery level display**: Real-time battery percentage with dynamic icon - **Battery level display**: Real-time battery percentage with dynamic icon
@ -567,8 +568,8 @@ The following changes **automatically require** PRD updates:
### Clock Tab ### Clock Tab
1. **View time**: Real-time clock display 1. **View time**: Real-time clock display
2. **Access settings**: Tap gear icon in navigation bar 2. **Access settings**: Tap gear icon in navigation bar
3. **Enter display mode**: Long-press anywhere on clock (0.6 seconds) 3. **Automatic Full-Screen**: UI automatically hides after 5 seconds of inactivity
4. **Exit display mode**: Long-press again to return to normal mode 4. **Restore UI**: Tap anywhere to bring back the navigation and tab bar
### Settings ### Settings
1. **Time format**: Toggle 24-hour, seconds, AM/PM display 1. **Time format**: Toggle 24-hour, seconds, AM/PM display

View File

@ -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 - Clock tab hides the status bar for a distraction-free display
- Selectable animation styles: None, Spring, Bounce, and Glitch - Selectable animation styles: None, Spring, Bounce, and Glitch
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons - 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** **White Noise**
- Multiple ambient categories and curated sound packs - 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 - Snooze support via AlarmKit's countdown feature
**Display Mode** **Display Mode**
- Long-press to enter immersive display mode - Automatic full-screen display after 5 seconds of inactivity
- Auto-hides navigation and status bar - Tap anywhere to restore navigation and status bar
- Optional wake-lock to keep the screen on - Optional wake-lock to keep the screen on
### What's New ### What's New

View File

@ -94,10 +94,10 @@ struct ContentView: View {
.onChange(of: selectedTab) { oldValue, newValue in .onChange(of: selectedTab) { oldValue, newValue in
Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)") Design.debugLog("[ContentView] Tab changed: \(oldValue) -> \(newValue)")
if oldValue == .clock && newValue != .clock { if oldValue == .clock && newValue != .clock {
Design.debugLog("[ContentView] Leaving clock tab, setting displayMode to false") Design.debugLog("[ContentView] Leaving clock tab, setting fullScreenMode to false")
// Safety net: also explicitly disable display mode when leaving clock tab // Safety net: also explicitly disable full-screen mode when leaving clock tab
// The ClockView's toolbar modifier already responds to isOnClockTab changing // The ClockView's toolbar modifier already responds to isOnClockTab changing
clockViewModel.setDisplayMode(false) clockViewModel.setFullScreenMode(false)
} }
} }
.accentColor(AppAccent.primary) .accentColor(AppAccent.primary)

View File

@ -18,6 +18,9 @@ import Bedrock
@Observable @Observable
class AlarmService { class AlarmService {
// MARK: - Singleton
static let shared = AlarmService()
// MARK: - Properties // MARK: - Properties
private(set) var alarms: [Alarm] = [] private(set) var alarms: [Alarm] = []
private var alarmLookup: [UUID: Int] = [:] private var alarmLookup: [UUID: Int] = [:]
@ -37,6 +40,7 @@ class AlarmService {
alarms.append(alarm) alarms.append(alarm)
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
} }
/// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService. /// Update an alarm in storage. Does NOT reschedule - caller should use AlarmKitService.
@ -49,6 +53,7 @@ class AlarmService {
alarms[index] = alarm alarms[index] = alarm
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
} }
/// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService. /// Delete an alarm from storage. Does NOT cancel - caller should use AlarmKitService.
@ -57,6 +62,7 @@ class AlarmService {
alarms.removeAll { $0.id == id } alarms.removeAll { $0.id == id }
updateAlarmLookup() updateAlarmLookup()
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
} }
/// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService. /// Toggle an alarm's enabled state. Does NOT reschedule - caller should use AlarmKitService.
@ -65,6 +71,7 @@ class AlarmService {
alarms[index].isEnabled.toggle() alarms[index].isEnabled.toggle()
Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)") Design.debugLog("[alarms] AlarmService.toggleAlarm: \(id) now enabled=\(alarms[index].isEnabled)")
saveAlarms() saveAlarms()
NotificationCenter.default.post(name: .clockStyleDidUpdate, object: nil)
} }
func getAlarm(id: UUID) -> Alarm? { func getAlarm(id: UUID) -> Alarm? {
@ -129,7 +136,11 @@ class AlarmService {
} }
/// Get all enabled alarms (for rescheduling with AlarmKit) /// Get all enabled alarms (for rescheduling with AlarmKit)
func getEnabledAlarms() -> [Alarm] { var enabledAlarms: [Alarm] {
return alarms.filter { $0.isEnabled } return alarms.filter { $0.isEnabled }
} }
func getEnabledAlarms() -> [Alarm] {
return enabledAlarms
}
} }

View File

@ -34,7 +34,7 @@ class AlarmViewModel {
} }
// MARK: - Initialization // MARK: - Initialization
init(alarmService: AlarmService = AlarmService()) { init(alarmService: AlarmService = AlarmService.shared) {
self.alarmService = alarmService self.alarmService = alarmService
} }

View File

@ -21,6 +21,9 @@ struct AlarmView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad let isPad = UIDevice.current.userInterfaceIdiom == .pad
ZStack {
AppSurface.primary.ignoresSafeArea()
Group { Group {
if viewModel.alarms.isEmpty { if viewModel.alarms.isEmpty {
VStack(spacing: Design.Spacing.large) { VStack(spacing: Design.Spacing.large) {
@ -39,14 +42,10 @@ struct AlarmView: View {
.frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} else { } else {
List { ScrollView {
VStack(spacing: Design.Spacing.medium) {
if !isKeepAwakeEnabled { if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner() AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} }
ForEach(viewModel.alarms) { alarm in ForEach(viewModel.alarms) { alarm in
@ -62,13 +61,15 @@ struct AlarmView: View {
} }
) )
} }
.onDelete(perform: deleteAlarm)
} }
.listStyle(.insetGrouped) .padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.large)
}
.frame(maxWidth: Design.Size.maxContentWidthPortrait) .frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
} }
}
.navigationTitle(isPad ? "" : "Alarms") .navigationTitle(isPad ? "" : "Alarms")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@ -20,6 +20,7 @@ struct AlarmRowView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
HStack { HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(alarm.formattedTime()) Text(alarm.formattedTime())
@ -48,10 +49,15 @@ struct AlarmRowView: View {
Spacer() Spacer()
Toggle("", isOn: Binding( SettingsToggle(
title: "",
subtitle: "",
isOn: Binding(
get: { alarm.isEnabled }, get: { alarm.isEnabled },
set: { _ in onToggle() } set: { _ in onToggle() }
)) ),
accentColor: AppAccent.primary
)
.labelsHidden() .labelsHidden()
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -59,6 +65,7 @@ struct AlarmRowView: View {
onEdit() onEdit()
} }
} }
}
private var isKeepAwakeEnabled: Bool { private var isKeepAwakeEnabled: Bool {
guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else { guard let decoded = try? JSONDecoder().decode(ClockStyle.self, from: clockStyleData) else {

View File

@ -16,19 +16,53 @@ struct EmptyAlarmsView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.large) {
// Icon Spacer()
Image(systemName: "alarm")
.font(.largeTitle)
.foregroundColor(.secondary)
// Instructional text // Icon with subtle animation
Text("Create an alarm to begin") ZStack {
.font(.subheadline) Circle()
.foregroundColor(.secondary) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.clear) .background(AppSurface.primary)
} }
} }

View File

@ -14,15 +14,22 @@ struct LabelEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.large) { Form {
Section {
TextField("Alarm Label", text: $label) TextField("Alarm Label", text: $label)
.textFieldStyle(RoundedBorderTextFieldStyle()) .typography(.body)
.contentPadding(horizontal: Design.Spacing.large) .foregroundStyle(AppTextColors.primary)
.padding(.vertical, Design.Spacing.small)
Spacer() } footer: {
Text("Enter a name for your alarm.")
.typography(.caption)
.foregroundStyle(AppTextColors.secondary)
} }
.listRowBackground(AppSurface.card)
}
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Label") .navigationTitle("Label")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.contentPadding(vertical: Design.Spacing.large)
} }
} }

View File

@ -14,37 +14,24 @@ struct NotificationMessageEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.large) { Form {
TextField("Notification message", text: $message) Section {
.textFieldStyle(RoundedBorderTextFieldStyle()) TextEditor(text: $message)
.contentPadding(horizontal: Design.Spacing.large) .frame(minHeight: 100)
.typography(.body)
// Preview section .foregroundStyle(AppTextColors.primary)
VStack(alignment: .leading, spacing: Design.Spacing.small) { .padding(.vertical, Design.Spacing.xxSmall)
Text("Preview:") } footer: {
.font(.headline) Text("This message will appear when the alarm rings.")
.foregroundColor(.secondary) .typography(.caption)
.foregroundStyle(AppTextColors.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() .listRowBackground(AppSurface.card)
.background(Color(.systemGray6))
.cornerRadius(8)
}
.contentPadding(horizontal: Design.Spacing.large)
Spacer()
} }
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Message") .navigationTitle("Message")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.contentPadding(vertical: Design.Spacing.large)
} }
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Bedrock
/// View for selecting snooze duration /// View for selecting snooze duration
struct SnoozeSelectionView: View { struct SnoozeSelectionView: View {
@ -20,6 +21,7 @@ struct SnoozeSelectionView: View {
ForEach(snoozeOptions, id: \.self) { duration in ForEach(snoozeOptions, id: \.self) { duration in
HStack { HStack {
Text("\(duration) minutes") Text("\(duration) minutes")
.foregroundColor(AppTextColors.primary)
Spacer() Spacer()
if snoozeDuration == duration { if snoozeDuration == duration {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@ -27,12 +29,16 @@ struct SnoozeSelectionView: View {
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.listRowBackground(AppSurface.card)
.onTapGesture { .onTapGesture {
snoozeDuration = duration snoozeDuration = duration
} }
} }
} }
} }
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Snooze") .navigationTitle("Snooze")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Bedrock
import AudioPlaybackKit import AudioPlaybackKit
/// View for selecting alarm sounds with preview functionality /// View for selecting alarm sounds with preview functionality
@ -27,6 +28,7 @@ struct SoundSelectionView: View {
HStack { HStack {
Text(sound.name) Text(sound.name)
.font(.body) .font(.body)
.foregroundColor(AppTextColors.primary)
Spacer() Spacer()
if selectedSound == sound.fileName { if selectedSound == sound.fileName {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@ -34,6 +36,7 @@ struct SoundSelectionView: View {
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.listRowBackground(AppSurface.card)
.onTapGesture { .onTapGesture {
// Stop any currently playing sound when selecting a new one // Stop any currently playing sound when selecting a new one
if isPlaying { if isPlaying {
@ -44,6 +47,9 @@ struct SoundSelectionView: View {
} }
} }
} }
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Sound") .navigationTitle("Sound")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@ -28,8 +28,9 @@ struct TimePickerSection: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped() .clipped()
} }
.background(Color(.systemGroupedBackground)) .background(AppSurface.primary)
} }
.frame(maxWidth: .infinity)
.frame(height: 200) .frame(height: 200)
.onOrientationChange() // Force updates on orientation changes .onOrientationChange() // Force updates on orientation changes
} }

View File

@ -25,8 +25,9 @@ struct TimeUntilAlarmSection: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Color(.systemGroupedBackground)) .background(AppSurface.primary)
} }
private var timeUntilAlarm: String { private var timeUntilAlarm: String {

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Bedrock
import AudioPlaybackKit import AudioPlaybackKit
import Foundation import Foundation
@ -69,11 +70,13 @@ struct EditAlarmView: View {
.foregroundColor(AppAccent.primary) .foregroundColor(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Label") Text("Label")
.foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
Text(alarmLabel) Text(alarmLabel)
.foregroundColor(.secondary) .foregroundColor(AppTextColors.secondary)
} }
} }
.listRowBackground(AppSurface.card)
// Notification Message Section // Notification Message Section
NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) { NavigationLink(destination: NotificationMessageEditView(message: $notificationMessage)) {
@ -82,12 +85,14 @@ struct EditAlarmView: View {
.foregroundColor(AppAccent.primary) .foregroundColor(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Message") Text("Message")
.foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
Text(notificationMessage) Text(notificationMessage)
.foregroundColor(.secondary) .foregroundColor(AppTextColors.secondary)
.lineLimit(1) .lineLimit(1)
} }
} }
.listRowBackground(AppSurface.card)
// Sound Section // Sound Section
NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) { NavigationLink(destination: SoundSelectionView(selectedSound: $selectedSoundName)) {
@ -96,11 +101,13 @@ struct EditAlarmView: View {
.foregroundColor(AppAccent.primary) .foregroundColor(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Sound") Text("Sound")
.foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
Text(getSoundDisplayName(selectedSoundName)) Text(getSoundDisplayName(selectedSoundName))
.foregroundColor(.secondary) .foregroundColor(AppTextColors.secondary)
} }
} }
.listRowBackground(AppSurface.card)
// Snooze Section // Snooze Section
NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) { NavigationLink(destination: SnoozeSelectionView(snoozeDuration: $snoozeDuration)) {
@ -109,13 +116,17 @@ struct EditAlarmView: View {
.foregroundColor(AppAccent.primary) .foregroundColor(AppAccent.primary)
.frame(width: 24) .frame(width: 24)
Text("Snooze") Text("Snooze")
.foregroundStyle(AppTextColors.primary)
Spacer() Spacer()
Text("for \(snoozeDuration) min") Text("for \(snoozeDuration) min")
.foregroundColor(.secondary) .foregroundColor(AppTextColors.secondary)
} }
} }
.listRowBackground(AppSurface.card)
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
} }
.navigationTitle("Edit Alarm") .navigationTitle("Edit Alarm")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@ -46,6 +46,8 @@ class ClockStyle: Codable, Equatable {
// MARK: - Overlay Settings // MARK: - Overlay Settings
var showBattery: Bool = true var showBattery: Bool = true
var showDate: 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 dateFormat: String = "d MMMM EEE" // Default: "7 September Mon"
var clockOpacity: Double = AppConstants.Defaults.clockOpacity var clockOpacity: Double = AppConstants.Defaults.clockOpacity
var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity var overlayOpacity: Double = AppConstants.Defaults.overlayOpacity
@ -83,6 +85,8 @@ class ClockStyle: Codable, Equatable {
case digitAnimationStyle case digitAnimationStyle
case showBattery case showBattery
case showDate case showDate
case showNextAlarm
case showNoiseControls
case dateFormat case dateFormat
case clockOpacity case clockOpacity
case overlayOpacity case overlayOpacity
@ -135,6 +139,8 @@ class ClockStyle: Codable, Equatable {
} }
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate 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.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
self.overlayOpacity = try container.decodeIfPresent(Double.self, forKey: .overlayOpacity) ?? self.overlayOpacity 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(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle)
try container.encode(showBattery, forKey: .showBattery) try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate) 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(dateFormat, forKey: .dateFormat)
try container.encode(clockOpacity, forKey: .clockOpacity) try container.encode(clockOpacity, forKey: .clockOpacity)
try container.encode(overlayOpacity, forKey: .overlayOpacity) try container.encode(overlayOpacity, forKey: .overlayOpacity)
@ -463,6 +471,8 @@ class ClockStyle: Codable, Equatable {
lhs.digitAnimationStyle == rhs.digitAnimationStyle && lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
lhs.showBattery == rhs.showBattery && lhs.showBattery == rhs.showBattery &&
lhs.showDate == rhs.showDate && lhs.showDate == rhs.showDate &&
lhs.showNextAlarm == rhs.showNextAlarm &&
lhs.showNoiseControls == rhs.showNoiseControls &&
lhs.dateFormat == rhs.dateFormat && lhs.dateFormat == rhs.dateFormat &&
lhs.clockOpacity == rhs.clockOpacity && lhs.clockOpacity == rhs.clockOpacity &&
lhs.overlayOpacity == rhs.overlayOpacity && lhs.overlayOpacity == rhs.overlayOpacity &&

View File

@ -19,7 +19,7 @@ class ClockViewModel {
// MARK: - Properties // MARK: - Properties
private(set) var currentTime = Date() private(set) var currentTime = Date()
private(set) var style = ClockStyle() private(set) var style = ClockStyle()
private(set) var isDisplayMode = false private(set) var isFullScreenMode = false
// Wake lock service // Wake lock service
private let wakeLockService = WakeLockService.shared private let wakeLockService = WakeLockService.shared
@ -65,28 +65,28 @@ class ClockViewModel {
} }
// MARK: - Public Interface // MARK: - Public Interface
func toggleDisplayMode() { func toggleFullScreenMode() {
let oldValue = isDisplayMode let oldValue = isFullScreenMode
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { 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() updateWakeLockState()
if isDisplayMode { if isFullScreenMode {
requestKeepAwakePromptIfNeeded() requestKeepAwakePromptIfNeeded()
} }
} }
func setDisplayMode(_ enabled: Bool) { func setFullScreenMode(_ enabled: Bool) {
guard isDisplayMode != enabled else { guard isFullScreenMode != enabled else {
Design.debugLog("[ClockViewModel] setDisplayMode(\(enabled)) - already at this value, skipping") Design.debugLog("[ClockViewModel] setFullScreenMode(\(enabled)) - already at this value, skipping")
return return
} }
Design.debugLog("[ClockViewModel] setDisplayMode: \(isDisplayMode) -> \(enabled)") Design.debugLog("[ClockViewModel] setFullScreenMode: \(isFullScreenMode) -> \(enabled)")
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) { withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode = enabled isFullScreenMode = enabled
} }
updateWakeLockState() updateWakeLockState()
if enabled { if enabled {
@ -110,6 +110,8 @@ class ClockViewModel {
style.fontDesign = newStyle.fontDesign style.fontDesign = newStyle.fontDesign
style.showBattery = newStyle.showBattery style.showBattery = newStyle.showBattery
style.showDate = newStyle.showDate style.showDate = newStyle.showDate
style.showNextAlarm = newStyle.showNextAlarm
style.showNoiseControls = newStyle.showNoiseControls
style.overlayOpacity = newStyle.overlayOpacity style.overlayOpacity = newStyle.overlayOpacity
style.backgroundHex = newStyle.backgroundHex style.backgroundHex = newStyle.backgroundHex
style.keepAwake = newStyle.keepAwake style.keepAwake = newStyle.keepAwake
@ -237,8 +239,8 @@ class ClockViewModel {
/// Update wake lock state based on current settings /// Update wake lock state based on current settings
private func updateWakeLockState() { private func updateWakeLockState() {
// Enable wake lock if in display mode and keep awake is enabled // Enable wake lock if in full-screen mode and keep awake is enabled
if isDisplayMode && style.keepAwake { if isFullScreenMode && style.keepAwake {
wakeLockService.enableWakeLock() wakeLockService.enableWakeLock()
} else { } else {
wakeLockService.disableWakeLock() wakeLockService.disableWakeLock()

View File

@ -24,9 +24,9 @@ struct ClockView: View {
/// Tab bar should ONLY be hidden when BOTH conditions are true: /// Tab bar should ONLY be hidden when BOTH conditions are true:
/// 1. We're on the clock tab (prevents hiding when user switches away) /// 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 { private var shouldHideTabBar: Bool {
isOnClockTab && viewModel.isDisplayMode isOnClockTab && viewModel.isFullScreenMode
} }
// MARK: - Body // MARK: - Body
@ -54,7 +54,7 @@ struct ClockView: View {
ClockDisplayContainer( ClockDisplayContainer(
currentTime: viewModel.currentTime, currentTime: viewModel.currentTime,
style: viewModel.style, style: viewModel.style,
isDisplayMode: viewModel.isDisplayMode isFullScreenMode: viewModel.isFullScreenMode
) )
.padding(.leading, symmetricInset) .padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset) .padding(.trailing, symmetricInset)
@ -92,7 +92,7 @@ struct ClockView: View {
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately // This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar) .toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
.onChange(of: shouldHideTabBar) { oldValue, newValue in .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( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
@ -116,8 +116,8 @@ struct ClockView: View {
idleTimer?.invalidate() idleTimer?.invalidate()
idleTimer = nil idleTimer = nil
} }
.onChange(of: viewModel.isDisplayMode) { _, isDisplayMode in .onChange(of: viewModel.isFullScreenMode) { _, isFullScreenMode in
if isDisplayMode { if isFullScreenMode {
idleTimer?.invalidate() idleTimer?.invalidate()
idleTimer = nil idleTimer = nil
} else { } else {
@ -130,29 +130,29 @@ struct ClockView: View {
private func resetIdleTimer() { private func resetIdleTimer() {
idleTimer?.invalidate() idleTimer?.invalidate()
idleTimer = nil idleTimer = nil
guard !viewModel.isDisplayMode else { return } guard !viewModel.isFullScreenMode else { return }
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
enterDisplayModeFromIdle() enterFullScreenFromIdle()
} }
} }
private func enterDisplayModeFromIdle() { private func enterFullScreenFromIdle() {
// Guard against entering display mode if we're no longer on the clock tab // Guard against entering full-screen if we're no longer on the clock tab
guard isViewActive else { 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 return
} }
guard !viewModel.isDisplayMode else { guard !viewModel.isFullScreenMode else {
Design.debugLog("[ClockView] enterDisplayModeFromIdle - BLOCKED: already in display mode") Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: already in full-screen")
return return
} }
Design.debugLog("[ClockView] enterDisplayModeFromIdle - entering display mode") Design.debugLog("[ClockView] enterFullScreenFromIdle - entering full-screen")
viewModel.toggleDisplayMode() viewModel.toggleFullScreenMode()
} }
private func handleUserInteraction() { private func handleUserInteraction() {
if viewModel.isDisplayMode { if viewModel.isFullScreenMode {
viewModel.toggleDisplayMode() viewModel.toggleFullScreenMode()
} }
resetIdleTimer() resetIdleTimer()
} }

View File

@ -14,13 +14,13 @@ struct ClockDisplayContainer: View {
// MARK: - Properties // MARK: - Properties
let currentTime: Date let currentTime: Date
let style: ClockStyle let style: ClockStyle
let isDisplayMode: Bool let isFullScreenMode: Bool
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let isPortrait = geometry.size.height >= geometry.size.width 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 let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
// Time display - fills all available space // Time display - fills all available space
@ -36,13 +36,13 @@ struct ClockDisplayContainer: View {
fontWeight: style.fontWeight, fontWeight: style.fontWeight,
fontDesign: style.fontDesign, fontDesign: style.fontDesign,
forceHorizontalMode: style.forceHorizontalMode, forceHorizontalMode: style.forceHorizontalMode,
isDisplayMode: isDisplayMode, isDisplayMode: isFullScreenMode,
animationStyle: style.digitAnimationStyle animationStyle: style.digitAnimationStyle
) )
.padding(.top, topSpacing) .padding(.top, topSpacing)
.frame(width: geometry.size.width, height: geometry.size.height) .frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity) .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( ClockDisplayContainer(
currentTime: Date(), currentTime: Date(),
style: ClockStyle(), style: ClockStyle(),
isDisplayMode: false isFullScreenMode: false
) )
.frame(width: 400, height: 600) .frame(width: 400, height: 600)
.background(Color.black) .background(Color.black)

View File

@ -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: {})
}

View File

@ -17,13 +17,15 @@ struct ClockOverlayContainer: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
VStack { VStack {
if style.showBattery || style.showDate { if style.showBattery || style.showDate || style.showNextAlarm || style.showNoiseControls {
TopOverlayView( TopOverlayView(
showBattery: style.showBattery, showBattery: style.showBattery,
showDate: style.showDate, showDate: style.showDate,
color: style.effectiveDigitColor, color: style.effectiveDigitColor,
opacity: style.clockOpacity, opacity: style.clockOpacity,
dateFormat: style.dateFormat dateFormat: style.dateFormat,
showNextAlarm: style.showNextAlarm,
showNoiseControls: style.showNoiseControls
) )
.padding(.top, Design.Spacing.small) .padding(.top, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -20,20 +20,24 @@ struct AdvancedAppearanceSection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(spacing: 0) {
Text("Digit Animation") SettingsNavigationRow(
.font(.subheadline) title: "Digit Animation",
.foregroundStyle(AppTextColors.secondary) subtitle: style.digitAnimationStyle.displayName,
backgroundColor: .clear
) {
SettingsSelectionView(
selection: $style.digitAnimationStyle,
options: DigitAnimationStyle.allCases,
title: "Digit Animation",
toString: { $0.displayName }
)
}
Picker("Digit Animation", selection: $style.digitAnimationStyle) { Rectangle()
ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in .fill(AppBorder.subtle)
Text(animation.displayName).tag(animation) .frame(height: 1)
} .padding(.horizontal, Design.Spacing.medium)
}
.pickerStyle(.menu)
.tint(AppAccent.primary)
}
.padding(.vertical, Design.Spacing.xSmall)
SettingsToggle( SettingsToggle(
title: "Randomize Color", title: "Randomize Color",
@ -42,6 +46,11 @@ struct AdvancedAppearanceSection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsSlider( SettingsSlider(
title: "Glow", title: "Glow",
subtitle: "Adjust the glow intensity", subtitle: "Adjust the glow intensity",
@ -52,6 +61,11 @@ struct AdvancedAppearanceSection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsSlider( SettingsSlider(
title: "Clock Opacity", title: "Clock Opacity",
subtitle: "Set the clock transparency", subtitle: "Set the clock transparency",
@ -62,6 +76,7 @@ struct AdvancedAppearanceSection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
} }
}
Text("Fine-tune the visual appearance of your clock.") Text("Fine-tune the visual appearance of your clock.")
.font(.caption) .font(.caption)

View File

@ -20,6 +20,7 @@ struct AdvancedDisplaySection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle( SettingsToggle(
title: "Keep Awake", title: "Keep Awake",
subtitle: "Prevent sleep in display mode", subtitle: "Prevent sleep in display mode",
@ -27,6 +28,11 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Live Activities", title: "Live Activities",
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing", subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
@ -35,6 +41,11 @@ struct AdvancedDisplaySection: View {
) )
if style.autoBrightness { if style.autoBrightness {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
HStack { HStack {
Text("Current Brightness") Text("Current Brightness")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
@ -44,6 +55,9 @@ struct AdvancedDisplaySection: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
} }
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)
}
} }
} }

View File

@ -22,24 +22,21 @@ struct BasicAppearanceSection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { SettingsNavigationRow(
Text("Color Theme") title: "Color Theme",
.font(.subheadline.weight(.medium)) subtitle: style.selectedColorTheme,
.foregroundStyle(AppTextColors.primary) backgroundColor: .clear
) {
Picker("Color Theme", selection: $style.selectedColorTheme) { SettingsSelectionView(
ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in selection: $style.selectedColorTheme,
HStack { options: ClockStyle.availableColorThemes().map { $0.0 },
Circle() title: "Color Theme",
.fill(themeColor(for: theme.0)) toString: { theme in
.frame(width: 20, height: 20) ClockStyle.availableColorThemes().first(where: { $0.0 == theme })?.1 ?? theme
Text(theme.1)
} }
.tag(theme.0) )
} }
}
.pickerStyle(.menu)
.onChange(of: style.selectedColorTheme) { _, newTheme in .onChange(of: style.selectedColorTheme) { _, newTheme in
if newTheme != "Custom" { if newTheme != "Custom" {
style.applyColorTheme(newTheme) style.applyColorTheme(newTheme)
@ -47,13 +44,27 @@ struct BasicAppearanceSection: View {
backgroundColor = Color(hex: style.backgroundHex) ?? .black backgroundColor = Color(hex: style.backgroundHex) ?? .black
} }
} }
}
if style.selectedColorTheme == "Custom" { if style.selectedColorTheme == "Custom" {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false) ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
.foregroundStyle(AppTextColors.primary) .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) ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
.foregroundStyle(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
} }
} }
} }

View File

@ -20,6 +20,7 @@ struct BasicDisplaySection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle( SettingsToggle(
title: "24Hour Format", title: "24Hour Format",
subtitle: "Use military time", subtitle: "Use military time",
@ -27,6 +28,11 @@ struct BasicDisplaySection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Show Seconds", title: "Show Seconds",
subtitle: "Display seconds in the clock", subtitle: "Display seconds in the clock",
@ -35,6 +41,11 @@ struct BasicDisplaySection: View {
) )
if !style.use24Hour { if !style.use24Hour {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Show AM/PM", title: "Show AM/PM",
subtitle: "Add an AM/PM indicator", subtitle: "Add an AM/PM indicator",
@ -43,6 +54,11 @@ struct BasicDisplaySection: View {
) )
} }
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Auto Brightness", title: "Auto Brightness",
subtitle: "Adapt brightness to ambient light", subtitle: "Adapt brightness to ambient light",
@ -51,6 +67,11 @@ struct BasicDisplaySection: View {
) )
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown { if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Horizontal Mode", title: "Horizontal Mode",
subtitle: "Force a wide layout in portrait", subtitle: "Force a wide layout in portrait",
@ -59,6 +80,7 @@ struct BasicDisplaySection: View {
) )
} }
} }
}
Text("Basic display settings for your clock.") Text("Basic display settings for your clock.")
.font(.caption) .font(.caption)

View File

@ -37,18 +37,20 @@ struct FontSection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { // Font Family
Text("Family") SettingsNavigationRow(
.font(.subheadline.weight(.medium)) title: "Family",
.foregroundStyle(AppTextColors.primary) subtitle: style.fontFamily.rawValue,
backgroundColor: .clear
Picker("Family", selection: $style.fontFamily) { ) {
ForEach(sortedFontFamilies, id: \.self) { family in SettingsSelectionView(
Text(family.rawValue).tag(family) selection: $style.fontFamily,
options: sortedFontFamilies,
title: "Font Family",
toString: { $0.rawValue }
)
} }
}
.pickerStyle(.menu)
.onChange(of: style.fontFamily) { _, newFamily in .onChange(of: style.fontFamily) { _, newFamily in
if newFamily != .system { if newFamily != .system {
style.fontDesign = .default style.fontDesign = .default
@ -59,36 +61,52 @@ struct FontSection: View {
style.fontWeight = weights.first ?? .regular style.fontWeight = weights.first ?? .regular
} }
} }
}
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { Rectangle()
Text("Weight") .fill(AppBorder.subtle)
.font(.subheadline.weight(.medium)) .frame(height: 1)
.foregroundStyle(AppTextColors.primary) .padding(.horizontal, Design.Spacing.medium)
Picker("Weight", selection: $style.fontWeight) { // Font Weight
ForEach(availableWeights, id: \.self) { weight in SettingsNavigationRow(
Text(weight.rawValue).tag(weight) title: "Weight",
} subtitle: style.fontWeight.rawValue,
} backgroundColor: .clear
.pickerStyle(.menu) ) {
SettingsSelectionView(
selection: $style.fontWeight,
options: availableWeights,
title: "Font Weight",
toString: { $0.rawValue }
)
} }
if style.fontFamily == .system { if style.fontFamily == .system {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { Rectangle()
Text("Design") .fill(AppBorder.subtle)
.font(.subheadline.weight(.medium)) .frame(height: 1)
.foregroundStyle(AppTextColors.primary) .padding(.horizontal, Design.Spacing.medium)
Picker("Design", selection: $style.fontDesign) { // Font Design
ForEach(Font.Design.allCases, id: \.self) { design in SettingsNavigationRow(
Text(design.rawValue).tag(design) title: "Design",
} subtitle: style.fontDesign.rawValue,
} backgroundColor: .clear
.pickerStyle(.menu) ) {
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 { HStack {
Text("Preview") Text("Preview")
.font(.subheadline.weight(.medium)) .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)) .font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24))
.foregroundStyle(AppTextColors.primary) .foregroundStyle(AppTextColors.primary)
} }
.padding(Design.Spacing.medium)
} }
} }
} }

View File

@ -20,6 +20,7 @@ struct NightModeSection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle( SettingsToggle(
title: "Enable Night Mode", title: "Enable Night Mode",
subtitle: "Use a red clock for low light", subtitle: "Use a red clock for low light",
@ -27,6 +28,11 @@ struct NightModeSection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Auto Night Mode", title: "Auto Night Mode",
subtitle: "Trigger based on ambient light", subtitle: "Trigger based on ambient light",
@ -35,6 +41,11 @@ struct NightModeSection: View {
) )
if style.autoNightMode { if style.autoNightMode {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsSlider( SettingsSlider(
title: "Light Threshold", title: "Light Threshold",
subtitle: "Lower values activate sooner", subtitle: "Lower values activate sooner",
@ -46,6 +57,11 @@ struct NightModeSection: View {
) )
} }
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Scheduled Night Mode", title: "Scheduled Night Mode",
subtitle: "Enable on a daily schedule", subtitle: "Enable on a daily schedule",
@ -54,6 +70,11 @@ struct NightModeSection: View {
) )
if style.scheduledNightMode { if style.scheduledNightMode {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
HStack { HStack {
Text("Start Time") Text("Start Time")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
@ -61,6 +82,13 @@ struct NightModeSection: View {
Spacer() Spacer()
TimePickerView(timeString: $style.nightModeStartTime) 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 { HStack {
Text("End Time") Text("End Time")
@ -69,9 +97,16 @@ struct NightModeSection: View {
Spacer() Spacer()
TimePickerView(timeString: $style.nightModeEndTime) TimePickerView(timeString: $style.nightModeEndTime)
} }
.padding(.vertical, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.medium)
} }
if style.isNightModeActive { if style.isNightModeActive {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "moon.fill") Image(systemName: "moon.fill")
.foregroundStyle(AppStatus.error) .foregroundStyle(AppStatus.error)
@ -80,6 +115,9 @@ struct NightModeSection: View {
.foregroundStyle(AppStatus.error) .foregroundStyle(AppStatus.error)
Spacer() Spacer()
} }
.padding(.vertical, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.medium)
}
} }
} }

View File

@ -22,6 +22,7 @@ struct OverlaySection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle( SettingsToggle(
title: "Battery Level", title: "Battery Level",
subtitle: "Show battery percentage", subtitle: "Show battery percentage",
@ -29,6 +30,11 @@ struct OverlaySection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle( SettingsToggle(
title: "Date", title: "Date",
subtitle: "Display the current date", subtitle: "Display the current date",
@ -36,18 +42,50 @@ struct OverlaySection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
if style.showDate { Rectangle()
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { .fill(AppBorder.subtle)
Text("Date Format") .frame(height: 1)
.font(.subheadline.weight(.medium)) .padding(.horizontal, Design.Spacing.medium)
.foregroundStyle(AppTextColors.primary)
Picker("Date Format", selection: $style.dateFormat) { SettingsToggle(
ForEach(dateFormats, id: \.1) { format in title: "Next Alarm",
Text(format.0).tag(format.1) 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)
} }
} }
} }

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import AudioPlaybackKit
/// Component for displaying top overlay with battery and date information /// Component for displaying top overlay with battery and date information
struct TopOverlayView: View { struct TopOverlayView: View {
@ -17,11 +18,24 @@ struct TopOverlayView: View {
let color: Color let color: Color
let opacity: Double let opacity: Double
let dateFormat: String 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 // MARK: - Body
var body: some View { var body: some View {
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 { HStack {
if showDate { if showDate {
DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat) DateOverlayView(color: color, opacity: opacity, dateFormat: dateFormat)
@ -38,9 +52,42 @@ struct TopOverlayView: View {
) )
} }
} }
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(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
.transition(.opacity) .transition(.opacity)
.id(clockUpdateTrigger) // Force re-render on style or alarm changes
.onReceive(NotificationCenter.default.publisher(for: .clockStyleDidUpdate)) { _ in
clockUpdateTrigger.toggle()
}
.onAppear { .onAppear {
batteryService.startMonitoring() batteryService.startMonitoring()
} }

View File

@ -98,7 +98,7 @@ struct SoundCategoryView: View {
} }
private var soundGrid: some View { private var soundGrid: some View {
LazyVStack(spacing: Design.Spacing.small) { LazyVStack(spacing: Design.Spacing.medium) {
ForEach(filteredSounds) { sound in ForEach(filteredSounds) { sound in
SoundCard( SoundCard(
sound: sound, 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) { Button(action: action) {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .styled(.subheadingEmphasis)
if count > 0 { if count > 0 {
Text("(\(count))") Text("\(count)")
.font(.caption) .styled(.caption, emphasis: .secondary)
.foregroundColor(.secondary) .padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isSelected ? Color.white.opacity(0.2) : AppSurface.secondary)
.clipShape(Capsule())
} }
} }
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
.background(isSelected ? AppAccent.primary : Color(.systemGray6)) .background(isSelected ? AppAccent.primary : AppSurface.card)
.foregroundColor(isSelected ? .white : AppTextColors.primary) .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) .buttonStyle(.plain)
} }
@ -155,68 +163,45 @@ struct SoundCard: View {
let onPreview: () -> Void let onPreview: () -> Void
var body: some View { var body: some View {
Button(action: onSelect) {
SettingsCard(
backgroundColor: isSelected ? AppAccent.primary.opacity(0.15) : AppSurface.card,
borderColor: isSelected ? AppAccent.primary : AppBorder.subtle
) {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
// Sound Icon (Left) // Sound Icon (Left)
ZStack { ZStack {
Circle()
.fill(isSelected ? AppAccent.primary : Color(.systemGray5))
.frame(width: 50, height: 50)
Image(systemName: soundIcon) Image(systemName: soundIcon)
.font(.title3) .font(.title3)
.foregroundColor(isSelected ? .white : AppTextColors.primary) .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
if isPreviewing { if isPreviewing {
Circle() Circle()
.stroke(AppAccent.primary, lineWidth: 2) .stroke(AppAccent.primary, lineWidth: 2)
.frame(width: 58, height: 58) .frame(width: 40, height: 40)
.scaleEffect(1.02) .scaleEffect(1.05)
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing) .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
} }
} }
.frame(width: 48, height: 48)
// Sound Info (Right) // Sound Info (Center)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 2) {
// Sound Name
Text(sound.name) Text(sound.name)
.font(.subheadline.weight(.medium)) .styled(.subheadingEmphasis)
.foregroundColor(AppTextColors.primary) .foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
.lineLimit(1) .lineLimit(1)
// Description
Text(sound.description) Text(sound.description)
.font(.caption) .styled(.caption, emphasis: .secondary)
.foregroundColor(.secondary)
.lineLimit(2) .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())
Spacer()
}
} }
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 { .onLongPressGesture {
onPreview() onPreview()
} }

View File

@ -30,6 +30,9 @@ struct NoiseView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
ZStack {
AppSurface.primary.ignoresSafeArea()
GeometryReader { geometry in GeometryReader { geometry in
let isLandscape = geometry.size.width > geometry.size.height let isLandscape = geometry.size.width > geometry.size.height
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
@ -46,6 +49,7 @@ struct NoiseView: View {
.frame(maxWidth: maxWidth) .frame(maxWidth: maxWidth)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
}
.animation(.easeInOut(duration: 0.3), value: selectedSound) .animation(.easeInOut(duration: 0.3), value: selectedSound)
.searchable( .searchable(
text: $searchText, text: $searchText,
@ -79,23 +83,42 @@ struct NoiseView: View {
soundControlView soundControlView
.centered() .centered()
} else { } else {
// Placeholder when no sound selected // Placeholder when no sound selected - Enhanced for CRO
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.medium) {
Image(systemName: "music.note") ZStack {
.font(.largeTitle) Circle()
.foregroundColor(.secondary) .fill(AppAccent.primary.opacity(0.1))
.frame(width: 80, height: 80)
Text("Select a sound to begin") Image(systemName: "waveform")
.font(.subheadline) .font(.title)
.foregroundColor(.secondary) .foregroundColor(AppAccent.primary)
.symbolEffect(.variableColor.iterative, options: .repeating)
}
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) .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) .contentPadding(horizontal: Design.Spacing.large)
.padding(.top, Design.Spacing.large) .padding(.top, Design.Spacing.large)
.background(Color(.systemBackground)) .background(AppSurface.primary)
// Scrollable sound selection // Scrollable sound selection
ScrollView { ScrollView {
@ -106,6 +129,7 @@ struct NoiseView: View {
) )
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
} }
.scrollContentBackground(.hidden)
} }
} }
@ -121,18 +145,37 @@ struct NoiseView: View {
if selectedSound != nil { if selectedSound != nil {
soundControlView soundControlView
} else { } else {
// Placeholder when no sound selected // Placeholder when no sound selected - Enhanced for CRO
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.medium) {
Image(systemName: "music.note") ZStack {
.font(.largeTitle) Circle()
.foregroundColor(.secondary) .fill(AppAccent.primary.opacity(0.1))
.frame(width: 80, height: 80)
Text("Select a sound to begin") Image(systemName: "waveform")
.font(.subheadline) .font(.title)
.foregroundColor(.secondary) .foregroundColor(AppAccent.primary)
.symbolEffect(.variableColor.iterative, options: .repeating)
}
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) .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() Spacer()
@ -151,6 +194,7 @@ struct NoiseView: View {
) )
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
} }
.scrollContentBackground(.hidden)
} }
} }
.contentPadding(horizontal: Design.Spacing.medium) .contentPadding(horizontal: Design.Spacing.medium)

View File

@ -26,7 +26,7 @@ struct OnboardingView: View {
@State private var keepAwakeEnabled = false @State private var keepAwakeEnabled = false
@State private var showCelebration = false @State private var showCelebration = false
private let totalPages = 3 private let totalPages = 4
// MARK: - Body // MARK: - Body
@ -42,11 +42,14 @@ struct OnboardingView: View {
welcomeWithClockPage welcomeWithClockPage
.tag(0) .tag(0)
permissionsPage whiteNoisePage
.tag(1) .tag(1)
getStartedPage permissionsPage
.tag(2) .tag(2)
getStartedPage
.tag(3)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut(duration: 0.3), value: currentPage) .animation(.easeInOut(duration: 0.3), value: currentPage)
@ -71,7 +74,9 @@ struct OnboardingView: View {
Spacer() Spacer()
// Live clock preview - immediate value using TimelineView // Live clock preview - immediate value using TimelineView
liveClockPreview TimelineView(.periodic(from: .now, by: 1.0)) { context in
OnboardingClockText(date: context.date)
}
.padding(.bottom, Design.Spacing.medium) .padding(.bottom, Design.Spacing.medium)
Text("The Noise Clock") Text("The Noise Clock")
@ -93,8 +98,8 @@ struct OnboardingView: View {
text: "Wake up gently, on your terms" text: "Wake up gently, on your terms"
) )
featureHighlight( featureHighlight(
icon: "hand.tap.fill", icon: "clock.fill",
text: "Long-press for immersive mode" text: "Automatic full-screen display"
) )
} }
.padding(.top, Design.Spacing.large) .padding(.top, Design.Spacing.large)
@ -104,12 +109,6 @@ struct OnboardingView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 { private func featureHighlight(icon: String, text: String) -> some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon) Image(systemName: icon)
@ -126,7 +125,18 @@ struct OnboardingView: View {
.padding(.horizontal, Design.Spacing.xxLarge) .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 { private var permissionsPage: some View {
VStack(spacing: Design.Spacing.xxLarge) { 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 { private var getStartedPage: some View {
VStack(spacing: Design.Spacing.xxLarge) { VStack(spacing: Design.Spacing.xxLarge) {
@ -276,7 +286,7 @@ struct OnboardingView: View {
.typography(.heroBold) .typography(.heroBold)
.foregroundStyle(AppTextColors.primary) .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) .typography(.body)
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -285,7 +295,7 @@ struct OnboardingView: View {
// Quick tips // Quick tips
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
tipRow(icon: "alarm.fill", text: "Create your first alarm") 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") tipRow(icon: "speaker.wave.2", text: "Tap Noise to play sounds")
} }
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)
@ -402,7 +412,7 @@ struct OnboardingView: View {
if granted { if granted {
try? await Task.sleep(for: .milliseconds(800)) try? await Task.sleep(for: .milliseconds(800))
withAnimation { withAnimation {
currentPage = 2 currentPage = 3
} }
} }
} }

View File

@ -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<T: Hashable>: 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 }
)
}
}