Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
9ae5aef89b
commit
0909f93368
11
PRD.md
11
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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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,12 +89,21 @@ 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()
|
||||
|
||||
|
||||
@ -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
|
||||
/// 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
|
||||
|
||||
if self.isFocusModeActive {
|
||||
DebugLogger.log("Focus mode is active", category: .settings)
|
||||
} else {
|
||||
DebugLogger.log("Focus mode is not active", category: .settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugLogger.log(
|
||||
"Notification settings updated: auth=\(settings.authorizationStatus), timeSensitive=\(settings.timeSensitiveSetting), scheduledDelivery=\(settings.scheduledDeliverySetting)",
|
||||
category: .settings
|
||||
)
|
||||
}
|
||||
|
||||
/// Get notification authorization status
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user