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

This commit is contained in:
Matt Bruce 2026-01-31 09:52:11 -06:00
parent 9ae5aef89b
commit 0909f93368
17 changed files with 528 additions and 398 deletions

11
PRD.md
View File

@ -397,7 +397,16 @@ TheNoiseClock/
│ │ │ ├── DotCircle.swift # Individual dot component for colons │ │ │ ├── DotCircle.swift # Individual dot component for colons
│ │ │ ├── BatteryOverlayView.swift # Battery level overlay │ │ │ ├── BatteryOverlayView.swift # Battery level overlay
│ │ │ ├── DateOverlayView.swift # Date display overlay │ │ │ ├── DateOverlayView.swift # Date display overlay
│ │ │ └── TopOverlayView.swift # Combined overlay container │ │ │ ├── TopOverlayView.swift # Combined overlay container
│ │ │ └── Settings/
│ │ │ ├── BasicAppearanceSection.swift
│ │ │ ├── BasicDisplaySection.swift
│ │ │ ├── AdvancedAppearanceSection.swift
│ │ │ ├── AdvancedDisplaySection.swift
│ │ │ ├── FontSection.swift
│ │ │ ├── NightModeSection.swift
│ │ │ ├── OverlaySection.swift
│ │ │ └── TimePickerView.swift
│ │ ├── Alarms/ │ │ ├── Alarms/
│ │ │ ├── AlarmView.swift # Main alarm management view │ │ │ ├── AlarmView.swift # Main alarm management view
│ │ │ ├── AddAlarmView.swift # Alarm creation interface │ │ │ ├── AddAlarmView.swift # Alarm creation interface

View File

@ -175,6 +175,7 @@ private struct TestContentView: View {
TimeDisplayView(date: newDate, TimeDisplayView(date: newDate,
use24Hour: true, use24Hour: true,
showSeconds: false, showSeconds: false,
showAmPm: true,
digitColor: .primary, digitColor: .primary,
glowIntensity: 0.5, glowIntensity: 0.5,
manualScale: 1.0, manualScale: 1.0,

View File

@ -15,6 +15,7 @@ class ClockStyle: Codable, Equatable {
// MARK: - Time Format Settings // MARK: - Time Format Settings
var use24Hour: Bool = true var use24Hour: Bool = true
var showSeconds: Bool = false var showSeconds: Bool = false
var showAmPm: Bool = true
var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait
// MARK: - Visual Settings // MARK: - Visual Settings
@ -26,7 +27,7 @@ class ClockStyle: Codable, Equatable {
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
// MARK: - Color Theme Settings // MARK: - Color Theme Settings
var selectedColorTheme: String = "Custom" // Custom, Red, Orange, Yellow, Green, Blue, Purple, Pink, White var selectedColorTheme: String = "Custom" // Custom, Night, Day, Red, Orange, Yellow, Green, Blue, Purple, Pink, White
// MARK: - Night Mode Settings // MARK: - Night Mode Settings
var nightModeEnabled: Bool = false var nightModeEnabled: Bool = false
@ -61,6 +62,7 @@ class ClockStyle: Codable, Equatable {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case use24Hour case use24Hour
case showSeconds case showSeconds
case showAmPm
case forceHorizontalMode case forceHorizontalMode
case digitColorHex case digitColorHex
case randomizeColor case randomizeColor
@ -99,6 +101,7 @@ class ClockStyle: Codable, Equatable {
self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour
self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds self.showSeconds = try container.decodeIfPresent(Bool.self, forKey: .showSeconds) ?? self.showSeconds
self.showAmPm = try container.decodeIfPresent(Bool.self, forKey: .showAmPm) ?? self.showAmPm
self.forceHorizontalMode = try container.decodeIfPresent(Bool.self, forKey: .forceHorizontalMode) ?? self.forceHorizontalMode self.forceHorizontalMode = try container.decodeIfPresent(Bool.self, forKey: .forceHorizontalMode) ?? self.forceHorizontalMode
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
@ -142,6 +145,7 @@ class ClockStyle: Codable, Equatable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(use24Hour, forKey: .use24Hour) try container.encode(use24Hour, forKey: .use24Hour)
try container.encode(showSeconds, forKey: .showSeconds) try container.encode(showSeconds, forKey: .showSeconds)
try container.encode(showAmPm, forKey: .showAmPm)
try container.encode(forceHorizontalMode, forKey: .forceHorizontalMode) try container.encode(forceHorizontalMode, forKey: .forceHorizontalMode)
try container.encode(digitColorHex, forKey: .digitColorHex) try container.encode(digitColorHex, forKey: .digitColorHex)
try container.encode(randomizeColor, forKey: .randomizeColor) try container.encode(randomizeColor, forKey: .randomizeColor)
@ -199,6 +203,12 @@ class ClockStyle: Codable, Equatable {
selectedColorTheme = theme selectedColorTheme = theme
switch theme { switch theme {
case "Night":
digitColorHex = "#FFFFFF"
backgroundHex = "#000000"
case "Day":
digitColorHex = "#000000"
backgroundHex = "#FFFFFF"
case "Red": case "Red":
digitColorHex = "#FF3B30" digitColorHex = "#FF3B30"
backgroundHex = "#000000" backgroundHex = "#000000"
@ -235,6 +245,8 @@ class ClockStyle: Codable, Equatable {
static func availableColorThemes() -> [(String, String)] { static func availableColorThemes() -> [(String, String)] {
return [ return [
("Custom", "Custom"), ("Custom", "Custom"),
("Night", "Night"),
("Day", "Day"),
("Red", "Red"), ("Red", "Red"),
("Orange", "Orange"), ("Orange", "Orange"),
("Yellow", "Yellow"), ("Yellow", "Yellow"),
@ -357,6 +369,10 @@ class ClockStyle: Codable, Equatable {
/// Get base brightness recommendation for current color theme /// Get base brightness recommendation for current color theme
private func getBaseBrightnessForColor() -> Double { private func getBaseBrightnessForColor() -> Double {
switch selectedColorTheme { switch selectedColorTheme {
case "Night":
return 0.8
case "Day":
return 0.5
case "Red", "Orange": case "Red", "Orange":
return 0.6 // Warmer colors work well at lower brightness return 0.6 // Warmer colors work well at lower brightness
case "Yellow", "White": case "Yellow", "White":
@ -423,6 +439,7 @@ class ClockStyle: Codable, Equatable {
static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool { static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool {
lhs.use24Hour == rhs.use24Hour && lhs.use24Hour == rhs.use24Hour &&
lhs.showSeconds == rhs.showSeconds && lhs.showSeconds == rhs.showSeconds &&
lhs.showAmPm == rhs.showAmPm &&
lhs.forceHorizontalMode == rhs.forceHorizontalMode && lhs.forceHorizontalMode == rhs.forceHorizontalMode &&
lhs.digitColorHex == rhs.digitColorHex && lhs.digitColorHex == rhs.digitColorHex &&
lhs.randomizeColor == rhs.randomizeColor && lhs.randomizeColor == rhs.randomizeColor &&

View File

@ -81,6 +81,7 @@ class AlarmService {
// Schedule new notification if enabled // Schedule new notification if enabled
if alarm.isEnabled { if alarm.isEnabled {
Task { Task {
let respectFocusModes = currentRespectFocusModes()
// Use FocusModeService for better Focus mode compatibility // Use FocusModeService for better Focus mode compatibility
focusModeService.scheduleAlarmNotification( focusModeService.scheduleAlarmNotification(
identifier: alarm.id.uuidString, identifier: alarm.id.uuidString,
@ -88,11 +89,20 @@ class AlarmService {
body: alarm.notificationMessage, body: alarm.notificationMessage,
date: alarm.time, date: alarm.time,
soundName: alarm.soundName, soundName: alarm.soundName,
repeats: false // For now, set to false since Alarm model doesn't have repeatDays repeats: false, // For now, set to false since Alarm model doesn't have repeatDays
respectFocusModes: respectFocusModes
) )
} }
} }
} }
private func currentRespectFocusModes() -> Bool {
guard let data = UserDefaults.standard.data(forKey: ClockStyle.appStorageKey),
let style = try? JSONDecoder().decode(ClockStyle.self, from: data) else {
return ClockStyle().respectFocusModes
}
return style.respectFocusModes
}
private func saveAlarms() { private func saveAlarms() {
persistenceWorkItem?.cancel() persistenceWorkItem?.cancel()

View File

@ -6,10 +6,11 @@
// //
import Foundation import Foundation
import UserNotifications
import Observation import Observation
import UIKit
import UserNotifications
/// Service to handle Focus mode interactions and ensure app functionality /// Service to align notifications with Focus mode behavior
@Observable @Observable
class FocusModeService { class FocusModeService {
@ -17,9 +18,10 @@ class FocusModeService {
static let shared = FocusModeService() static let shared = FocusModeService()
// MARK: - Properties // MARK: - Properties
private(set) var isFocusModeActive = false private(set) var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined
private(set) var currentFocusMode: String? private(set) var timeSensitiveSetting: UNNotificationSetting = .notSupported
private var focusModeObserver: NSObjectProtocol? private(set) var scheduledDeliverySetting: UNNotificationSetting = .notSupported
private var notificationSettingsObserver: NSObjectProtocol?
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
@ -33,13 +35,8 @@ class FocusModeService {
// MARK: - Public Interface // MARK: - Public Interface
/// Check if Focus mode is currently active /// Check if Focus mode is currently active
var isActive: Bool { var isAuthorized: Bool {
return isFocusModeActive notificationAuthorizationStatus == .authorized
}
/// Get the current Focus mode name if available
var activeFocusMode: String? {
return currentFocusMode
} }
/// Request notification permissions that work with Focus modes /// Request notification permissions that work with Focus modes
@ -54,6 +51,8 @@ class FocusModeService {
await configureNotificationSettings() await configureNotificationSettings()
} }
await refreshNotificationSettings()
return granted return granted
} catch { } catch {
DebugLogger.log("Error requesting notification permissions: \(error)", category: .general) DebugLogger.log("Error requesting notification permissions: \(error)", category: .general)
@ -95,7 +94,8 @@ class FocusModeService {
body: String, body: String,
date: Date, date: Date,
soundName: String, soundName: String,
repeats: Bool = false repeats: Bool = false,
respectFocusModes: Bool = true
) { ) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
@ -110,6 +110,10 @@ class FocusModeService {
DebugLogger.log("Sound file should be in main bundle: \(soundName)", category: .settings) DebugLogger.log("Sound file should be in main bundle: \(soundName)", category: .settings)
} }
content.categoryIdentifier = "ALARM_CATEGORY" content.categoryIdentifier = "ALARM_CATEGORY"
if !respectFocusModes, timeSensitiveSetting == .enabled {
content.interruptionLevel = .timeSensitive
}
content.userInfo = [ content.userInfo = [
"alarmId": identifier, "alarmId": identifier,
"soundName": soundName, "soundName": soundName,
@ -162,45 +166,37 @@ class FocusModeService {
/// Set up monitoring for Focus mode changes /// Set up monitoring for Focus mode changes
private func setupFocusModeMonitoring() { private func setupFocusModeMonitoring() {
// Monitor notification center for Focus mode changes notificationSettingsObserver = NotificationCenter.default.addObserver(
focusModeObserver = NotificationCenter.default.addObserver( forName: UIApplication.willEnterForegroundNotification,
forName: .NSSystemTimeZoneDidChange,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.updateFocusModeStatus() Task { await self?.refreshNotificationSettings() }
} }
// Initial status check Task { await refreshNotificationSettings() }
updateFocusModeStatus()
} }
/// Remove Focus mode observer /// Remove Focus mode observer
private func removeFocusModeObserver() { private func removeFocusModeObserver() {
if let observer = focusModeObserver { if let observer = notificationSettingsObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
focusModeObserver = nil notificationSettingsObserver = nil
} }
} }
/// Update Focus mode status /// Refresh notification settings to align with Focus mode behavior.
private func updateFocusModeStatus() { @MainActor
// Check if Focus mode is active by examining notification settings func refreshNotificationSettings() async {
UNUserNotificationCenter.current().getNotificationSettings { settings in let settings = await UNUserNotificationCenter.current().notificationSettings()
DispatchQueue.main.async { notificationAuthorizationStatus = settings.authorizationStatus
// This is a simplified check - in a real implementation, timeSensitiveSetting = settings.timeSensitiveSetting
// you might need to use private APIs or other methods scheduledDeliverySetting = settings.scheduledDeliverySetting
// to detect Focus mode status
self.isFocusModeActive = settings.authorizationStatus == .authorized DebugLogger.log(
self.currentFocusMode = self.isFocusModeActive ? "Active" : nil "Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)",
category: .settings
if self.isFocusModeActive { )
DebugLogger.log("Focus mode is active", category: .settings)
} else {
DebugLogger.log("Focus mode is not active", category: .settings)
}
}
}
} }
/// Get notification authorization status /// Get notification authorization status

View File

@ -74,6 +74,7 @@ class ClockViewModel {
// This preserves the @Observable chain // This preserves the @Observable chain
style.use24Hour = newStyle.use24Hour style.use24Hour = newStyle.use24Hour
style.showSeconds = newStyle.showSeconds style.showSeconds = newStyle.showSeconds
style.showAmPm = newStyle.showAmPm
style.forceHorizontalMode = newStyle.forceHorizontalMode style.forceHorizontalMode = newStyle.forceHorizontalMode
style.digitColorHex = newStyle.digitColorHex style.digitColorHex = newStyle.digitColorHex
style.glowIntensity = newStyle.glowIntensity style.glowIntensity = newStyle.glowIntensity

View File

@ -74,362 +74,6 @@ struct ClockSettingsView: View {
} }
} }
// MARK: - Supporting Views
// MARK: - Basic Settings Sections
private struct BasicAppearanceSection: View {
@Binding var style: ClockStyle
@Binding var digitColor: Color
@Binding var backgroundColor: Color
let onCommit: (ClockStyle) -> Void
var body: some View {
Section(header: Text("Colors"), footer: Text("Choose your favorite color theme or create a custom look.")) {
// Color Theme Picker
Picker("Color Theme", selection: $style.selectedColorTheme) {
ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in
HStack {
Circle()
.fill(themeColor(for: theme.0))
.frame(width: 20, height: 20)
Text(theme.1)
}
.tag(theme.0)
}
}
.pickerStyle(.menu)
.onChange(of: style.selectedColorTheme) { _, newTheme in
if newTheme != "Custom" {
style.applyColorTheme(newTheme)
digitColor = Color(hex: style.digitColorHex) ?? .white
backgroundColor = Color(hex: style.backgroundHex) ?? .black
}
}
// Custom color pickers (only show if Custom is selected)
if style.selectedColorTheme == "Custom" {
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
}
}
.onChange(of: backgroundColor) { _, newValue in
style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache()
}
.onChange(of: digitColor) { _, newValue in
style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache()
}
}
/// Get the color for a theme
private func themeColor(for theme: String) -> Color {
switch theme {
case "Custom":
return .gray
case "Red":
return .red
case "Orange":
return .orange
case "Yellow":
return .yellow
case "Green":
return .green
case "Blue":
return .blue
case "Purple":
return .purple
case "Pink":
return .pink
case "White":
return .white
default:
return .gray
}
}
}
private struct BasicDisplaySection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Display"), footer: Text("Basic display settings for your clock.")) {
Toggle("24Hour Format", isOn: $style.use24Hour)
Toggle("Show Seconds", isOn: $style.showSeconds)
Toggle("Auto Brightness", isOn: $style.autoBrightness)
// Only show horizontal mode option in portrait orientation
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
Toggle("Horizontal Mode", isOn: $style.forceHorizontalMode)
}
}
}
}
// MARK: - Advanced Settings Sections
private struct AdvancedAppearanceSection: View {
@Binding var style: ClockStyle
@Binding var digitColor: Color
@Binding var backgroundColor: Color
let onCommit: (ClockStyle) -> Void
var body: some View {
Section(header: Text("Advanced Appearance"), footer: Text("Fine-tune the visual appearance of your clock.")) {
Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor)
Toggle("Stretched (auto-fit)", isOn: $style.stretched)
if !style.stretched {
HStack {
Text("Size")
Slider(value: $style.digitScale, in: 0.0...1.0)
Text("\(Int((min(max(style.digitScale, 0.0), 1.0)) * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
HStack {
Text("Glow")
Slider(value: $style.glowIntensity, in: 0...1)
Text("\(Int(style.glowIntensity * 100))%")
.frame(width: 50, alignment: .trailing)
}
HStack {
Text("Clock Opacity")
Slider(value: $style.clockOpacity, in: 0.0...1.0)
Text("\(Int(style.clockOpacity * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
}
}
private struct AdvancedDisplaySection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Advanced Display"), footer: Text("Advanced display and system integration settings.")) {
Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake)
if style.autoBrightness {
HStack {
Text("Current Brightness")
Spacer()
Text("\(Int(style.effectiveBrightness * 100))%")
.foregroundColor(.secondary)
}
}
}
Section(header: Text("Focus Modes"), footer: Text("Control how the app behaves when Focus modes (Do Not Disturb) are active.")) {
Toggle("Respect Focus Modes", isOn: $style.respectFocusModes)
}
}
}
private struct FontSection: View {
@Binding var style: ClockStyle
// Use the enum allCases for font options
// Computed property for available weights based on selected font
private var availableWeights: [Font.Weight] {
if style.fontFamily == .system {
return Font.Weight.allCases
} else {
return style.fontFamily.fontWeights
}
}
// Computed property for sorted font families (System first, then alphabetical)
private var sortedFontFamilies: [FontFamily] {
let allFamilies = FontFamily.allCases
let systemFamily = allFamilies.filter { $0 == .system }
let otherFamilies = allFamilies.filter { $0 != .system }.sorted { $0.rawValue < $1.rawValue }
return systemFamily + otherFamilies
}
var body: some View {
Section(header: Text("Font")) {
// Font Family
Picker("Family", selection: $style.fontFamily) {
ForEach(sortedFontFamilies, id: \.self) { family in
Text(family.rawValue).tag(family)
}
}
.pickerStyle(.menu)
.onChange(of: style.fontFamily) { _, newFamily in
// Auto-set design to default for non-system fonts
if newFamily != .system {
style.fontDesign = .default
}
// Auto-set weight to first available weight if current weight is not available
let availableWeights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights
if !availableWeights.contains(style.fontWeight) {
style.fontWeight = availableWeights.first ?? .regular
}
}
// Font Weight - show available weights for selected font
Picker("Weight", selection: $style.fontWeight) {
ForEach(availableWeights, id: \.self) { weight in
Text(weight.rawValue).tag(weight)
}
}
.pickerStyle(.menu)
// Font Design - only show for system font
if style.fontFamily == .system {
Picker("Design", selection: $style.fontDesign) {
ForEach(Font.Design.allCases, id: \.self) { design in
Text(design.rawValue).tag(design)
}
}
.pickerStyle(.menu)
}
// Font Preview
HStack {
Text("Preview:")
.foregroundColor(.secondary)
Spacer()
Text("12:34")
.font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24))
.foregroundColor(.primary)
}
}
}
}
private struct NightModeSection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Night Mode"), footer: Text("Night mode displays the clock in red to reduce eye strain in low light environments.")) {
Toggle("Enable Night Mode", isOn: $style.nightModeEnabled)
Toggle("Auto Night Mode", isOn: $style.autoNightMode)
if style.autoNightMode {
HStack {
Text("Light Threshold")
Spacer()
Slider(value: $style.ambientLightThreshold, in: 0.1...0.8)
Text("\(Int(style.ambientLightThreshold * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
Toggle("Scheduled Night Mode", isOn: $style.scheduledNightMode)
if style.scheduledNightMode {
HStack {
Text("Start Time")
Spacer()
TimePickerView(timeString: $style.nightModeStartTime)
}
HStack {
Text("End Time")
Spacer()
TimePickerView(timeString: $style.nightModeEndTime)
}
}
if style.isNightModeActive {
HStack {
Image(systemName: "moon.fill")
.foregroundColor(.red)
Text("Night Mode Active")
.foregroundColor(.red)
Spacer()
}
}
}
}
}
private struct OverlaySection: View {
@Binding var style: ClockStyle
private let dateFormats = Date.availableDateFormats()
var body: some View {
Section(header: Text("Overlays")) {
HStack {
Text("Overlay Opacity")
Slider(value: $style.overlayOpacity, in: 0.0...1.0)
Text("\(Int(style.overlayOpacity * 100))%")
.frame(width: 50, alignment: .trailing)
}
Toggle("Battery Level", isOn: $style.showBattery)
Toggle("Date", isOn: $style.showDate)
if style.showDate {
Picker("Date Format", selection: $style.dateFormat) {
ForEach(dateFormats, id: \.1) { format in
Text(format.0).tag(format.1)
}
}
.pickerStyle(.menu)
}
}
}
}
private struct TimePickerView: View {
@Binding var timeString: String
@State private var selectedTime = Date()
var body: some View {
DatePicker("", selection: $selectedTime, displayedComponents: .hourAndMinute)
.labelsHidden()
.onAppear {
updateSelectedTimeFromString()
}
.onChange(of: selectedTime) { _, newTime in
updateStringFromTime(newTime)
}
.onChange(of: timeString) { _, _ in
updateSelectedTimeFromString()
}
}
private func updateSelectedTimeFromString() {
let components = timeString.split(separator: ":")
guard components.count == 2,
let hour = Int(components[0]),
let minute = Int(components[1]) else {
return
}
let calendar = Calendar.current
let now = Date()
let dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
var newComponents = dateComponents
newComponents.hour = hour
newComponents.minute = minute
if let newDate = calendar.date(from: newComponents) {
selectedTime = newDate
}
}
private func updateStringFromTime(_ time: Date) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
timeString = formatter.string(from: time)
}
}
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {
ClockSettingsView( ClockSettingsView(

View File

@ -34,6 +34,7 @@ struct ClockDisplayContainer: View {
date: currentTime, date: currentTime,
use24Hour: style.use24Hour, use24Hour: style.use24Hour,
showSeconds: style.showSeconds, showSeconds: style.showSeconds,
showAmPm: style.showAmPm,
digitColor: style.effectiveDigitColor, digitColor: style.effectiveDigitColor,
glowIntensity: style.glowIntensity, glowIntensity: style.glowIntensity,
manualScale: style.digitScale, manualScale: style.digitScale,

View File

@ -0,0 +1,46 @@
//
// AdvancedAppearanceSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct AdvancedAppearanceSection: View {
@Binding var style: ClockStyle
@Binding var digitColor: Color
@Binding var backgroundColor: Color
let onCommit: (ClockStyle) -> Void
var body: some View {
Section(header: Text("Advanced Appearance"), footer: Text("Fine-tune the visual appearance of your clock.")) {
Toggle("Randomize Color (every minute)", isOn: $style.randomizeColor)
Toggle("Stretched (auto-fit)", isOn: $style.stretched)
if !style.stretched {
HStack {
Text("Size")
Slider(value: $style.digitScale, in: 0.0...1.0)
Text("\(Int((min(max(style.digitScale, 0.0), 1.0)) * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
HStack {
Text("Glow")
Slider(value: $style.glowIntensity, in: 0...1)
Text("\(Int(style.glowIntensity * 100))%")
.frame(width: 50, alignment: .trailing)
}
HStack {
Text("Clock Opacity")
Slider(value: $style.clockOpacity, in: 0.0...1.0)
Text("\(Int(style.clockOpacity * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
}
}

View File

@ -0,0 +1,31 @@
//
// AdvancedDisplaySection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct AdvancedDisplaySection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Advanced Display"), footer: Text("Advanced display and system integration settings.")) {
Toggle("Keep Awake in Display Mode", isOn: $style.keepAwake)
if style.autoBrightness {
HStack {
Text("Current Brightness")
Spacer()
Text("\(Int(style.effectiveBrightness * 100))%")
.foregroundColor(.secondary)
}
}
}
Section(header: Text("Focus Modes"), footer: Text("Control how the app behaves when Focus modes (Do Not Disturb) are active.")) {
Toggle("Respect Focus Modes", isOn: $style.respectFocusModes)
}
}
}

View File

@ -0,0 +1,86 @@
//
// BasicAppearanceSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct BasicAppearanceSection: View {
@Binding var style: ClockStyle
@Binding var digitColor: Color
@Binding var backgroundColor: Color
let onCommit: (ClockStyle) -> Void
var body: some View {
Section(header: Text("Colors"), footer: Text("Choose your favorite color theme or create a custom look.")) {
// Color Theme Picker
Picker("Color Theme", selection: $style.selectedColorTheme) {
ForEach(ClockStyle.availableColorThemes(), id: \.0) { theme in
HStack {
Circle()
.fill(themeColor(for: theme.0))
.frame(width: 20, height: 20)
Text(theme.1)
}
.tag(theme.0)
}
}
.pickerStyle(.menu)
.onChange(of: style.selectedColorTheme) { _, newTheme in
if newTheme != "Custom" {
style.applyColorTheme(newTheme)
digitColor = Color(hex: style.digitColorHex) ?? .white
backgroundColor = Color(hex: style.backgroundHex) ?? .black
}
}
// Custom color pickers (only show if Custom is selected)
if style.selectedColorTheme == "Custom" {
ColorPicker("Digit Color", selection: $digitColor, supportsOpacity: false)
ColorPicker("Background Color", selection: $backgroundColor, supportsOpacity: true)
}
}
.onChange(of: backgroundColor) { _, newValue in
style.backgroundHex = newValue.toHex() ?? AppConstants.Defaults.backgroundColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache()
}
.onChange(of: digitColor) { _, newValue in
style.digitColorHex = newValue.toHex() ?? AppConstants.Defaults.digitColorHex
style.selectedColorTheme = "Custom"
style.clearColorCache()
}
}
/// Get the color for a theme
private func themeColor(for theme: String) -> Color {
switch theme {
case "Custom":
return .gray
case "Night":
return .white
case "Day":
return .black
case "Red":
return .red
case "Orange":
return .orange
case "Yellow":
return .yellow
case "Green":
return .green
case "Blue":
return .blue
case "Purple":
return .purple
case "Pink":
return .pink
case "White":
return .white
default:
return .gray
}
}
}

View File

@ -0,0 +1,30 @@
//
// BasicDisplaySection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct BasicDisplaySection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Display"), footer: Text("Basic display settings for your clock.")) {
Toggle("24Hour Format", isOn: $style.use24Hour)
Toggle("Show Seconds", isOn: $style.showSeconds)
if !style.use24Hour {
Toggle("Show AM/PM", isOn: $style.showAmPm)
}
Toggle("Auto Brightness", isOn: $style.autoBrightness)
// Only show horizontal mode option in portrait orientation
if UIDevice.current.orientation.isPortrait || UIDevice.current.orientation == .unknown {
Toggle("Horizontal Mode", isOn: $style.forceHorizontalMode)
}
}
}
}

View File

@ -0,0 +1,81 @@
//
// FontSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct FontSection: View {
@Binding var style: ClockStyle
// Computed property for available weights based on selected font
private var availableWeights: [Font.Weight] {
if style.fontFamily == .system {
return Font.Weight.allCases
} else {
return style.fontFamily.fontWeights
}
}
// Computed property for sorted font families (System first, then alphabetical)
private var sortedFontFamilies: [FontFamily] {
let allFamilies = FontFamily.allCases
let systemFamily = allFamilies.filter { $0 == .system }
let otherFamilies = allFamilies.filter { $0 != .system }.sorted { $0.rawValue < $1.rawValue }
return systemFamily + otherFamilies
}
var body: some View {
Section(header: Text("Font")) {
// Font Family
Picker("Family", selection: $style.fontFamily) {
ForEach(sortedFontFamilies, id: \.self) { family in
Text(family.rawValue).tag(family)
}
}
.pickerStyle(.menu)
.onChange(of: style.fontFamily) { _, newFamily in
// Auto-set design to default for non-system fonts
if newFamily != .system {
style.fontDesign = .default
}
// Auto-set weight to first available weight if current weight is not available
let weights = newFamily == .system ? Font.Weight.allCases : newFamily.fontWeights
if !weights.contains(style.fontWeight) {
style.fontWeight = weights.first ?? .regular
}
}
// Font Weight - show available weights for selected font
Picker("Weight", selection: $style.fontWeight) {
ForEach(availableWeights, id: \.self) { weight in
Text(weight.rawValue).tag(weight)
}
}
.pickerStyle(.menu)
// Font Design - only show for system font
if style.fontFamily == .system {
Picker("Design", selection: $style.fontDesign) {
ForEach(Font.Design.allCases, id: \.self) { design in
Text(design.rawValue).tag(design)
}
}
.pickerStyle(.menu)
}
// Font Preview
HStack {
Text("Preview:")
.foregroundColor(.secondary)
Spacer()
Text("12:34")
.font(FontUtils.createFont(name: style.fontFamily, weight: style.fontWeight, design: style.fontDesign, size: 24))
.foregroundColor(.primary)
}
}
}
}

View File

@ -0,0 +1,56 @@
//
// NightModeSection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct NightModeSection: View {
@Binding var style: ClockStyle
var body: some View {
Section(header: Text("Night Mode"), footer: Text("Night mode displays the clock in red to reduce eye strain in low light environments.")) {
Toggle("Enable Night Mode", isOn: $style.nightModeEnabled)
Toggle("Auto Night Mode", isOn: $style.autoNightMode)
if style.autoNightMode {
HStack {
Text("Light Threshold")
Spacer()
Slider(value: $style.ambientLightThreshold, in: 0.1...0.8)
Text("\(Int(style.ambientLightThreshold * 100))%")
.frame(width: 50, alignment: .trailing)
}
}
Toggle("Scheduled Night Mode", isOn: $style.scheduledNightMode)
if style.scheduledNightMode {
HStack {
Text("Start Time")
Spacer()
TimePickerView(timeString: $style.nightModeStartTime)
}
HStack {
Text("End Time")
Spacer()
TimePickerView(timeString: $style.nightModeEndTime)
}
}
if style.isNightModeActive {
HStack {
Image(systemName: "moon.fill")
.foregroundColor(.red)
Text("Night Mode Active")
.foregroundColor(.red)
Spacer()
}
}
}
}
}

View File

@ -0,0 +1,37 @@
//
// OverlaySection.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct OverlaySection: View {
@Binding var style: ClockStyle
private let dateFormats = Date.availableDateFormats()
var body: some View {
Section(header: Text("Overlays")) {
HStack {
Text("Overlay Opacity")
Slider(value: $style.overlayOpacity, in: 0.0...1.0)
Text("\(Int(style.overlayOpacity * 100))%")
.frame(width: 50, alignment: .trailing)
}
Toggle("Battery Level", isOn: $style.showBattery)
Toggle("Date", isOn: $style.showDate)
if style.showDate {
Picker("Date Format", selection: $style.dateFormat) {
ForEach(dateFormats, id: \.1) { format in
Text(format.0).tag(format.1)
}
}
.pickerStyle(.menu)
}
}
}
}

View File

@ -0,0 +1,54 @@
//
// TimePickerView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
struct TimePickerView: View {
@Binding var timeString: String
@State private var selectedTime = Date()
var body: some View {
DatePicker("", selection: $selectedTime, displayedComponents: .hourAndMinute)
.labelsHidden()
.onAppear {
updateSelectedTimeFromString()
}
.onChange(of: selectedTime) { _, newTime in
updateStringFromTime(newTime)
}
.onChange(of: timeString) { _, _ in
updateSelectedTimeFromString()
}
}
private func updateSelectedTimeFromString() {
let components = timeString.split(separator: ":")
guard components.count == 2,
let hour = Int(components[0]),
let minute = Int(components[1]) else {
return
}
let calendar = Calendar.current
let now = Date()
let dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
var newComponents = dateComponents
newComponents.hour = hour
newComponents.minute = minute
if let newDate = calendar.date(from: newComponents) {
selectedTime = newDate
}
}
private func updateStringFromTime(_ time: Date) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
timeString = formatter.string(from: time)
}
}

View File

@ -14,6 +14,7 @@ struct TimeDisplayView: View {
let date: Date let date: Date
let use24Hour: Bool let use24Hour: Bool
let showSeconds: Bool let showSeconds: Bool
let showAmPm: Bool
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let manualScale: Double let manualScale: Double
@ -48,6 +49,13 @@ struct TimeDisplayView: View {
return df return df
}() }()
private static let amPmDF: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "a"
return df
}()
private static let secondDF: DateFormatter = { private static let secondDF: DateFormatter = {
let df = DateFormatter() let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX") df.locale = Locale(identifier: "en_US_POSIX")
@ -68,6 +76,10 @@ struct TimeDisplayView: View {
let secondsText = Self.secondDF.string(from: date) let secondsText = Self.secondDF.string(from: date)
// AM/PM badge
let shouldShowAmPm = showAmPm && !use24Hour
let amPmText = Self.amPmDF.string(from: date).uppercased()
// Separators - reasonable spacing with extra padding in landscape // Separators - reasonable spacing with extra padding in landscape
let dotDiameter = fontSize * 0.75 let dotDiameter = fontSize * 0.75
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
@ -101,6 +113,23 @@ struct TimeDisplayView: View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.overlay(alignment: .bottomTrailing) {
if shouldShowAmPm {
Text(amPmText)
.font(
FontUtils.createFont(
name: fontFamily,
weight: fontWeight,
design: fontDesign,
size: max(12, fontSize * 0.18)
)
)
.foregroundColor(digitColor)
.opacity(clockOpacity)
.padding(.horizontal, max(6, fontSize * 0.05))
.padding(.vertical, max(3, fontSize * 0.03))
}
}
.offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode .offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode
.scaleEffect(finalScale, anchor: .center) .scaleEffect(finalScale, anchor: .center)
.animation(UIConstants.AnimationCurves.smooth, value: finalScale) .animation(UIConstants.AnimationCurves.smooth, value: finalScale)
@ -123,6 +152,7 @@ struct TimeDisplayView: View {
date: Date(), date: Date(),
use24Hour: true, use24Hour: true,
showSeconds: false, showSeconds: false,
showAmPm: true,
digitColor: .white, digitColor: .white,
glowIntensity: 0.2, glowIntensity: 0.2,
manualScale: 1.0, manualScale: 1.0,