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
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

View File

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

15
PRD.md
View File

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

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
- 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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,9 @@ struct AlarmView: View {
// MARK: - Body
var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
ZStack {
AppSurface.primary.ignoresSafeArea()
Group {
if viewModel.alarms.isEmpty {
VStack(spacing: Design.Spacing.large) {
@ -39,14 +42,10 @@ struct AlarmView: View {
.frame(maxWidth: Design.Size.maxContentWidthPortrait)
.frame(maxWidth: .infinity, alignment: .center)
} else {
List {
ScrollView {
VStack(spacing: Design.Spacing.medium) {
if !isKeepAwakeEnabled {
Section {
AlarmLimitationsBanner()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
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: .infinity, alignment: .center)
}
}
}
.navigationTitle(isPad ? "" : "Alarms")
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

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

View File

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

View File

@ -14,15 +14,22 @@ struct LabelEditView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: Design.Spacing.large) {
Form {
Section {
TextField("Alarm Label", text: $label)
.textFieldStyle(RoundedBorderTextFieldStyle())
.contentPadding(horizontal: Design.Spacing.large)
Spacer()
.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)
}
}

View File

@ -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)
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)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
.contentPadding(horizontal: Design.Spacing.large)
Spacer()
.listRowBackground(AppSurface.card)
}
.scrollContentBackground(.hidden)
.background(AppSurface.primary.ignoresSafeArea())
.navigationTitle("Message")
.navigationBarTitleDisplayMode(.inline)
.contentPadding(vertical: Design.Spacing.large)
}
}

View File

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

View File

@ -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 {

View File

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

View File

@ -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 {

View File

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

View File

@ -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 &&

View File

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

View File

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

View File

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

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

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

View File

@ -20,6 +20,7 @@ struct AdvancedDisplaySection: View {
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle(
title: "Keep Awake",
subtitle: "Prevent sleep in display mode",
@ -27,6 +28,11 @@ struct AdvancedDisplaySection: View {
accentColor: AppAccent.primary
)
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Live Activities",
subtitle: "Show alarms on Lock Screen/Dynamic Island while ringing",
@ -35,6 +41,11 @@ struct AdvancedDisplaySection: View {
)
if style.autoBrightness {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
HStack {
Text("Current Brightness")
.font(.subheadline.weight(.medium))
@ -44,6 +55,9 @@ struct AdvancedDisplaySection: View {
.font(.subheadline)
.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) {
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)
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
}
.tag(theme.0)
)
}
}
.pickerStyle(.menu)
.onChange(of: style.selectedColorTheme) { _, newTheme in
if newTheme != "Custom" {
style.applyColorTheme(newTheme)
@ -47,13 +44,27 @@ struct BasicAppearanceSection: View {
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)
}
}
}

View File

@ -20,6 +20,7 @@ struct BasicDisplaySection: View {
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle(
title: "24Hour Format",
subtitle: "Use military time",
@ -27,6 +28,11 @@ struct BasicDisplaySection: View {
accentColor: AppAccent.primary
)
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Show Seconds",
subtitle: "Display seconds in the clock",
@ -35,6 +41,11 @@ struct BasicDisplaySection: View {
)
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",
@ -43,6 +54,11 @@ struct BasicDisplaySection: View {
)
}
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Auto Brightness",
subtitle: "Adapt brightness to ambient light",
@ -51,6 +67,11 @@ struct BasicDisplaySection: View {
)
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",
@ -59,6 +80,7 @@ struct BasicDisplaySection: View {
)
}
}
}
Text("Basic display settings for your clock.")
.font(.caption)

View File

@ -37,18 +37,20 @@ 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 }
)
}
}
.pickerStyle(.menu)
.onChange(of: style.fontFamily) { _, newFamily in
if newFamily != .system {
style.fontDesign = .default
@ -59,36 +61,52 @@ struct FontSection: View {
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)
}
}
}

View File

@ -20,6 +20,7 @@ struct NightModeSection: View {
)
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(spacing: 0) {
SettingsToggle(
title: "Enable Night Mode",
subtitle: "Use a red clock for low light",
@ -27,6 +28,11 @@ struct NightModeSection: View {
accentColor: AppAccent.primary
)
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Auto Night Mode",
subtitle: "Trigger based on ambient light",
@ -35,6 +41,11 @@ struct NightModeSection: View {
)
if style.autoNightMode {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsSlider(
title: "Light Threshold",
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(
title: "Scheduled Night Mode",
subtitle: "Enable on a daily schedule",
@ -54,6 +70,11 @@ struct NightModeSection: View {
)
if style.scheduledNightMode {
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
HStack {
Text("Start Time")
.font(.subheadline.weight(.medium))
@ -61,6 +82,13 @@ struct NightModeSection: View {
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")
@ -69,9 +97,16 @@ struct NightModeSection: View {
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)
@ -80,6 +115,9 @@ struct NightModeSection: View {
.foregroundStyle(AppStatus.error)
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) {
VStack(spacing: 0) {
SettingsToggle(
title: "Battery Level",
subtitle: "Show battery percentage",
@ -29,6 +30,11 @@ struct OverlaySection: View {
accentColor: AppAccent.primary
)
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
SettingsToggle(
title: "Date",
subtitle: "Display the current date",
@ -36,18 +42,50 @@ struct OverlaySection: View {
accentColor: AppAccent.primary
)
if style.showDate {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Date Format")
.font(.subheadline.weight(.medium))
.foregroundStyle(AppTextColors.primary)
Rectangle()
.fill(AppBorder.subtle)
.frame(height: 1)
.padding(.horizontal, Design.Spacing.medium)
Picker("Date Format", selection: $style.dateFormat) {
ForEach(dateFormats, id: \.1) { format in
Text(format.0).tag(format.1)
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)
}
}
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import Bedrock
import AudioPlaybackKit
/// Component for displaying top overlay with battery and date information
struct TopOverlayView: View {
@ -17,11 +18,24 @@ 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 {
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)
@ -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(.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()
}

View File

@ -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 {
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 {
Circle()
.fill(isSelected ? AppAccent.primary : Color(.systemGray5))
.frame(width: 50, height: 50)
Image(systemName: soundIcon)
.font(.title3)
.foregroundColor(isSelected ? .white : AppTextColors.primary)
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
if isPreviewing {
Circle()
.stroke(AppAccent.primary, lineWidth: 2)
.frame(width: 58, height: 58)
.scaleEffect(1.02)
.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 (Right)
VStack(alignment: .leading, spacing: 4) {
// Sound Name
// Sound Info (Center)
VStack(alignment: .leading, spacing: 2) {
Text(sound.name)
.font(.subheadline.weight(.medium))
.foregroundColor(AppTextColors.primary)
.styled(.subheadingEmphasis)
.foregroundColor(isSelected ? AppAccent.primary : AppTextColors.primary)
.lineLimit(1)
// Description
Text(sound.description)
.font(.caption)
.foregroundColor(.secondary)
.styled(.caption, emphasis: .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())
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()
}

View File

@ -30,6 +30,9 @@ struct NoiseView: View {
// MARK: - Body
var body: some View {
ZStack {
AppSurface.primary.ignoresSafeArea()
GeometryReader { geometry in
let isLandscape = geometry.size.width > geometry.size.height
let maxWidth = isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
@ -46,6 +49,7 @@ struct NoiseView: View {
.frame(maxWidth: maxWidth)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.animation(.easeInOut(duration: 0.3), value: selectedSound)
.searchable(
text: $searchText,
@ -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)
Text("Select a sound to begin")
.font(.subheadline)
.foregroundColor(.secondary)
Image(systemName: "waveform")
.font(.title)
.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)
.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)
Text("Select a sound to begin")
.font(.subheadline)
.foregroundColor(.secondary)
Image(systemName: "waveform")
.font(.title)
.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)
.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)

View File

@ -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,7 +74,9 @@ struct OnboardingView: View {
Spacer()
// 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)
Text("The Noise Clock")
@ -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
}
}
}

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