From 0909f93368df39ddef8e0afb3c3d4b7fb563ba02 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sat, 31 Jan 2026 09:52:11 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 11 +- TheNoiseClock/Core/Utilities/FontUtils.swift | 1 + TheNoiseClock/Models/ClockStyle.swift | 19 +- TheNoiseClock/Services/AlarmService.swift | 12 +- TheNoiseClock/Services/FocusModeService.swift | 74 ++-- TheNoiseClock/ViewModels/ClockViewModel.swift | 1 + .../Views/Clock/ClockSettingsView.swift | 356 ------------------ .../Components/ClockDisplayContainer.swift | 1 + .../Settings/AdvancedAppearanceSection.swift | 46 +++ .../Settings/AdvancedDisplaySection.swift | 31 ++ .../Settings/BasicAppearanceSection.swift | 86 +++++ .../Settings/BasicDisplaySection.swift | 30 ++ .../Components/Settings/FontSection.swift | 81 ++++ .../Settings/NightModeSection.swift | 56 +++ .../Components/Settings/OverlaySection.swift | 37 ++ .../Components/Settings/TimePickerView.swift | 54 +++ .../Clock/Components/TimeDisplayView.swift | 30 ++ 17 files changed, 528 insertions(+), 398 deletions(-) create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/AdvancedAppearanceSection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/AdvancedDisplaySection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/BasicAppearanceSection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/BasicDisplaySection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/FontSection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/NightModeSection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/OverlaySection.swift create mode 100644 TheNoiseClock/Views/Clock/Components/Settings/TimePickerView.swift diff --git a/PRD.md b/PRD.md index 6713ad6..df60658 100644 --- a/PRD.md +++ b/PRD.md @@ -397,7 +397,16 @@ TheNoiseClock/ │ │ │ ├── DotCircle.swift # Individual dot component for colons │ │ │ ├── BatteryOverlayView.swift # Battery level 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/ │ │ │ ├── AlarmView.swift # Main alarm management view │ │ │ ├── AddAlarmView.swift # Alarm creation interface diff --git a/TheNoiseClock/Core/Utilities/FontUtils.swift b/TheNoiseClock/Core/Utilities/FontUtils.swift index a8754b0..222ad8c 100644 --- a/TheNoiseClock/Core/Utilities/FontUtils.swift +++ b/TheNoiseClock/Core/Utilities/FontUtils.swift @@ -175,6 +175,7 @@ private struct TestContentView: View { TimeDisplayView(date: newDate, use24Hour: true, showSeconds: false, + showAmPm: true, digitColor: .primary, glowIntensity: 0.5, manualScale: 1.0, diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index 87fcdc8..089dac0 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -15,6 +15,7 @@ class ClockStyle: Codable, Equatable { // MARK: - Time Format Settings var use24Hour: Bool = true var showSeconds: Bool = false + var showAmPm: Bool = true var forceHorizontalMode: Bool = false // Force horizontal layout even in portrait // MARK: - Visual Settings @@ -26,7 +27,7 @@ class ClockStyle: Codable, Equatable { var backgroundHex: String = AppConstants.Defaults.backgroundColorHex // 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 var nightModeEnabled: Bool = false @@ -61,6 +62,7 @@ class ClockStyle: Codable, Equatable { private enum CodingKeys: String, CodingKey { case use24Hour case showSeconds + case showAmPm case forceHorizontalMode case digitColorHex case randomizeColor @@ -99,6 +101,7 @@ class ClockStyle: Codable, Equatable { self.use24Hour = try container.decodeIfPresent(Bool.self, forKey: .use24Hour) ?? self.use24Hour 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.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex 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) try container.encode(use24Hour, forKey: .use24Hour) try container.encode(showSeconds, forKey: .showSeconds) + try container.encode(showAmPm, forKey: .showAmPm) try container.encode(forceHorizontalMode, forKey: .forceHorizontalMode) try container.encode(digitColorHex, forKey: .digitColorHex) try container.encode(randomizeColor, forKey: .randomizeColor) @@ -199,6 +203,12 @@ class ClockStyle: Codable, Equatable { selectedColorTheme = theme switch theme { + case "Night": + digitColorHex = "#FFFFFF" + backgroundHex = "#000000" + case "Day": + digitColorHex = "#000000" + backgroundHex = "#FFFFFF" case "Red": digitColorHex = "#FF3B30" backgroundHex = "#000000" @@ -235,6 +245,8 @@ class ClockStyle: Codable, Equatable { static func availableColorThemes() -> [(String, String)] { return [ ("Custom", "Custom"), + ("Night", "Night"), + ("Day", "Day"), ("Red", "Red"), ("Orange", "Orange"), ("Yellow", "Yellow"), @@ -357,6 +369,10 @@ class ClockStyle: Codable, Equatable { /// Get base brightness recommendation for current color theme private func getBaseBrightnessForColor() -> Double { switch selectedColorTheme { + case "Night": + return 0.8 + case "Day": + return 0.5 case "Red", "Orange": return 0.6 // Warmer colors work well at lower brightness case "Yellow", "White": @@ -423,6 +439,7 @@ class ClockStyle: Codable, Equatable { static func == (lhs: ClockStyle, rhs: ClockStyle) -> Bool { lhs.use24Hour == rhs.use24Hour && lhs.showSeconds == rhs.showSeconds && + lhs.showAmPm == rhs.showAmPm && lhs.forceHorizontalMode == rhs.forceHorizontalMode && lhs.digitColorHex == rhs.digitColorHex && lhs.randomizeColor == rhs.randomizeColor && diff --git a/TheNoiseClock/Services/AlarmService.swift b/TheNoiseClock/Services/AlarmService.swift index 0db925e..1c6c3a0 100644 --- a/TheNoiseClock/Services/AlarmService.swift +++ b/TheNoiseClock/Services/AlarmService.swift @@ -81,6 +81,7 @@ class AlarmService { // Schedule new notification if enabled if alarm.isEnabled { Task { + let respectFocusModes = currentRespectFocusModes() // Use FocusModeService for better Focus mode compatibility focusModeService.scheduleAlarmNotification( identifier: alarm.id.uuidString, @@ -88,11 +89,20 @@ class AlarmService { body: alarm.notificationMessage, date: alarm.time, 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() { persistenceWorkItem?.cancel() diff --git a/TheNoiseClock/Services/FocusModeService.swift b/TheNoiseClock/Services/FocusModeService.swift index d36a4fe..241e593 100644 --- a/TheNoiseClock/Services/FocusModeService.swift +++ b/TheNoiseClock/Services/FocusModeService.swift @@ -6,10 +6,11 @@ // import Foundation -import UserNotifications 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 class FocusModeService { @@ -17,9 +18,10 @@ class FocusModeService { static let shared = FocusModeService() // MARK: - Properties - private(set) var isFocusModeActive = false - private(set) var currentFocusMode: String? - private var focusModeObserver: NSObjectProtocol? + private(set) var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined + private(set) var timeSensitiveSetting: UNNotificationSetting = .notSupported + private(set) var scheduledDeliverySetting: UNNotificationSetting = .notSupported + private var notificationSettingsObserver: NSObjectProtocol? // MARK: - Initialization private init() { @@ -33,13 +35,8 @@ class FocusModeService { // MARK: - Public Interface /// Check if Focus mode is currently active - var isActive: Bool { - return isFocusModeActive - } - - /// Get the current Focus mode name if available - var activeFocusMode: String? { - return currentFocusMode + var isAuthorized: Bool { + notificationAuthorizationStatus == .authorized } /// Request notification permissions that work with Focus modes @@ -54,6 +51,8 @@ class FocusModeService { await configureNotificationSettings() } + await refreshNotificationSettings() + return granted } catch { DebugLogger.log("Error requesting notification permissions: \(error)", category: .general) @@ -95,7 +94,8 @@ class FocusModeService { body: String, date: Date, soundName: String, - repeats: Bool = false + repeats: Bool = false, + respectFocusModes: Bool = true ) { let content = UNMutableNotificationContent() content.title = title @@ -110,6 +110,10 @@ class FocusModeService { DebugLogger.log("Sound file should be in main bundle: \(soundName)", category: .settings) } content.categoryIdentifier = "ALARM_CATEGORY" + + if !respectFocusModes, timeSensitiveSetting == .enabled { + content.interruptionLevel = .timeSensitive + } content.userInfo = [ "alarmId": identifier, "soundName": soundName, @@ -162,45 +166,37 @@ class FocusModeService { /// Set up monitoring for Focus mode changes private func setupFocusModeMonitoring() { - // Monitor notification center for Focus mode changes - focusModeObserver = NotificationCenter.default.addObserver( - forName: .NSSystemTimeZoneDidChange, + notificationSettingsObserver = NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main ) { [weak self] _ in - self?.updateFocusModeStatus() + Task { await self?.refreshNotificationSettings() } } - // Initial status check - updateFocusModeStatus() + Task { await refreshNotificationSettings() } } /// Remove Focus mode observer private func removeFocusModeObserver() { - if let observer = focusModeObserver { + if let observer = notificationSettingsObserver { NotificationCenter.default.removeObserver(observer) - focusModeObserver = nil + notificationSettingsObserver = nil } } - /// Update Focus mode status - private func updateFocusModeStatus() { - // Check if Focus mode is active by examining notification settings - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - // This is a simplified check - in a real implementation, - // you might need to use private APIs or other methods - // to detect Focus mode status - self.isFocusModeActive = settings.authorizationStatus == .authorized - self.currentFocusMode = self.isFocusModeActive ? "Active" : nil - - if self.isFocusModeActive { - DebugLogger.log("Focus mode is active", category: .settings) - } else { - DebugLogger.log("Focus mode is not active", category: .settings) - } - } - } + /// Refresh notification settings to align with Focus mode behavior. + @MainActor + func refreshNotificationSettings() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationAuthorizationStatus = settings.authorizationStatus + timeSensitiveSetting = settings.timeSensitiveSetting + scheduledDeliverySetting = settings.scheduledDeliverySetting + + DebugLogger.log( + "Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)", + category: .settings + ) } /// Get notification authorization status diff --git a/TheNoiseClock/ViewModels/ClockViewModel.swift b/TheNoiseClock/ViewModels/ClockViewModel.swift index aa3d7c7..11a8c28 100644 --- a/TheNoiseClock/ViewModels/ClockViewModel.swift +++ b/TheNoiseClock/ViewModels/ClockViewModel.swift @@ -74,6 +74,7 @@ class ClockViewModel { // This preserves the @Observable chain style.use24Hour = newStyle.use24Hour style.showSeconds = newStyle.showSeconds + style.showAmPm = newStyle.showAmPm style.forceHorizontalMode = newStyle.forceHorizontalMode style.digitColorHex = newStyle.digitColorHex style.glowIntensity = newStyle.glowIntensity diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index 661d03b..3773aa6 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -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("24‑Hour 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 #Preview { ClockSettingsView( diff --git a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift index 788dbab..e20bf49 100644 --- a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift @@ -34,6 +34,7 @@ struct ClockDisplayContainer: View { date: currentTime, use24Hour: style.use24Hour, showSeconds: style.showSeconds, + showAmPm: style.showAmPm, digitColor: style.effectiveDigitColor, glowIntensity: style.glowIntensity, manualScale: style.digitScale, diff --git a/TheNoiseClock/Views/Clock/Components/Settings/AdvancedAppearanceSection.swift b/TheNoiseClock/Views/Clock/Components/Settings/AdvancedAppearanceSection.swift new file mode 100644 index 0000000..ef58dd1 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/AdvancedAppearanceSection.swift @@ -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) + } + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/AdvancedDisplaySection.swift b/TheNoiseClock/Views/Clock/Components/Settings/AdvancedDisplaySection.swift new file mode 100644 index 0000000..9021bfc --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/AdvancedDisplaySection.swift @@ -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) + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/BasicAppearanceSection.swift b/TheNoiseClock/Views/Clock/Components/Settings/BasicAppearanceSection.swift new file mode 100644 index 0000000..2beaf73 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/BasicAppearanceSection.swift @@ -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 + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/BasicDisplaySection.swift b/TheNoiseClock/Views/Clock/Components/Settings/BasicDisplaySection.swift new file mode 100644 index 0000000..c69823a --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/BasicDisplaySection.swift @@ -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("24‑Hour 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) + } + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/FontSection.swift b/TheNoiseClock/Views/Clock/Components/Settings/FontSection.swift new file mode 100644 index 0000000..8ab2ece --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/FontSection.swift @@ -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) + } + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/NightModeSection.swift b/TheNoiseClock/Views/Clock/Components/Settings/NightModeSection.swift new file mode 100644 index 0000000..d508f12 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/NightModeSection.swift @@ -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() + } + } + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/OverlaySection.swift b/TheNoiseClock/Views/Clock/Components/Settings/OverlaySection.swift new file mode 100644 index 0000000..120fa16 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/OverlaySection.swift @@ -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) + } + } + } +} diff --git a/TheNoiseClock/Views/Clock/Components/Settings/TimePickerView.swift b/TheNoiseClock/Views/Clock/Components/Settings/TimePickerView.swift new file mode 100644 index 0000000..7fd09c0 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/Settings/TimePickerView.swift @@ -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) + } +} diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift index 006fd32..1bea7bc 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -14,6 +14,7 @@ struct TimeDisplayView: View { let date: Date let use24Hour: Bool let showSeconds: Bool + let showAmPm: Bool let digitColor: Color let glowIntensity: Double let manualScale: Double @@ -48,6 +49,13 @@ struct TimeDisplayView: View { 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 = { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") @@ -68,6 +76,10 @@ struct TimeDisplayView: View { 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 let dotDiameter = fontSize * 0.75 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) + .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 .scaleEffect(finalScale, anchor: .center) .animation(UIConstants.AnimationCurves.smooth, value: finalScale) @@ -123,6 +152,7 @@ struct TimeDisplayView: View { date: Date(), use24Hour: true, showSeconds: false, + showAmPm: true, digitColor: .white, glowIntensity: 0.2, manualScale: 1.0,