diff --git a/TheNoiseClock/Core/Utilities/FontUtils.swift b/TheNoiseClock/Core/Utilities/FontUtils.swift index 4b884ef..882785e 100644 --- a/TheNoiseClock/Core/Utilities/FontUtils.swift +++ b/TheNoiseClock/Core/Utilities/FontUtils.swift @@ -5,98 +5,137 @@ // Created by Matt Bruce on 9/7/25. // +import Foundation +import UIKit import SwiftUI -/// Font sizing and typography utilities -enum FontUtils { +enum FontFamily: String, CaseIterable { + case system = "System" + case helvetica = "Helvetica" + case arial = "Arial" + case timesNewRoman = "TimesNewRomanPS" + case georgia = "Georgia" + case verdana = "Verdana" + case courier = "Courier" + case futura = "Futura" + case avenir = "Avenir" + case roboto = "Roboto" +} - /// Calculate optimal font size that maximizes space usage - /// - Parameters: - /// - containerWidth: Available container width - /// - containerHeight: Available container height - /// - isPortrait: Whether the device is in portrait orientation - /// - showSeconds: Whether seconds are displayed - /// - showAmPm: Whether AM/PM is displayed - /// - Returns: Optimal font size for maximum space utilization - static func optimalFontSize( - containerWidth: CGFloat, - containerHeight: CGFloat, - isPortrait: Bool, - showSeconds: Bool = false - ) -> CGFloat { - // Account for safe areas and padding - let safeInset = AppConstants.Defaults.safeInset - let availableWidth = max(1, containerWidth - safeInset * 2) - let availableHeight = max(1, containerHeight - safeInset * 2) - - // Calculate optimal size based on orientation and content - let optimalSize: CGFloat - if isPortrait { - // In portrait, height is the limiting factor - // Account for separators and spacing - let separatorHeight = availableHeight * 0.08 // 8% for separators (more conservative) - let contentHeight = availableHeight - separatorHeight - let estimatedLines = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM - let lineHeight = contentHeight / CGFloat(estimatedLines) - optimalSize = lineHeight * 0.75 // 75% of line height for actual text (more conservative) - } else { - // In landscape, be more aggressive with space usage - // Account for separators and spacing - let separatorWidth = availableWidth * 0.08 // 8% for separators (increased for safety) - let contentWidth = availableWidth - separatorWidth - let estimatedColumns = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM - let columnWidth = contentWidth / CGFloat(estimatedColumns) - optimalSize = columnWidth * 0.75 // 75% of column width for actual text (reduced for safety) +extension Font.Weight { + static var allCases: [Font.Weight] { + [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black] + } + var uiFontWeight: UIFont.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + default: return .regular } - - // Apply reasonable bounds - let minSize: CGFloat = 20 - let maxSize: CGFloat = AppConstants.Defaults.maxFontSize - return max(minSize, min(optimalSize, maxSize)) } - /// Calculate font size that fills available space with scaling - /// - Parameters: - /// - containerWidth: Available container width - /// - containerHeight: Available container height - /// - textContent: The actual text content to measure - /// - isPortrait: Whether the device is in portrait orientation - /// - Returns: Font size that maximizes space usage - static func fillSpaceFontSize( - containerWidth: CGFloat, - containerHeight: CGFloat, - textContent: String, - isPortrait: Bool - ) -> CGFloat { - let safeInset = AppConstants.Defaults.safeInset - let availableWidth = max(1, containerWidth - safeInset * 2) - let availableHeight = max(1, containerHeight - safeInset * 2) + var uiFontWeightSuffix: String { + switch self { + case .ultraLight: return "-UltraLight" + case .thin: return "-Thin" + case .light: return "-Light" + case .regular: return "" + case .medium: return "-Medium" + case .semibold: return "-SemiBold" + case .bold: return "-Bold" + case .heavy: return "-Heavy" + case .black: return "-Black" + default: return "" + } + } +} + +extension Font.Design { + static var allCases: [Font.Design] { + [.default, .rounded, .monospaced, .serif] + } + var uiFontWidth: UIFont.Width { + switch self { + case .default: return .standard + case .rounded: return .standard + case .monospaced: return .condensed + case .serif: return .standard + @unknown default: + return .standard + } + } +} + +/// Font sizing and typography utilities +struct FontUtils { + + static func weightedFontName(name: String, weight: Font.Weight, design: Font.Design) -> String { + let weightSuffix = weight.uiFontWeightSuffix - // Start with a reasonable base size - let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15 + switch design { + case .rounded: + if name.lowercased() == "system" { return "System" } + return name + (weightSuffix.isEmpty ? "-Rounded" : weightSuffix + "Rounded") + case .monospaced: + if name.lowercased() == "system" { return "Courier" } + return name == "Courier" ? name + weightSuffix : name + (weightSuffix.isEmpty ? "-Mono" : weightSuffix + "Mono") + case .serif: + if name.lowercased() == "system" { return "TimesNewRomanPS" } + return name + weightSuffix + default: + return name + weightSuffix + } + } + + static func calculateOptimalFontSize(digit: String, fontName: FontFamily, weight: Font.Weight, design: Font.Design, for size: CGSize) -> CGFloat { + var low: CGFloat = 1.0 + var high: CGFloat = 2000.0 - // Binary search for optimal size - var low: CGFloat = 10 - var high: CGFloat = AppConstants.Defaults.maxFontSize - var bestSize: CGFloat = baseSize - - for _ in 0..<10 { // Limit iterations - let testSize = (low + high) / 2 - let font = UIFont.systemFont(ofSize: testSize, weight: .bold) - let textSize = measureTextSize(text: textContent, font: font) + while high - low > 0.01 { + let mid = (low + high) / 2 + let testFont = createUIFont(name: fontName, weight: weight, design: design, size: mid) + let textSize = tightBoundingBox(for: digit, withFont: testFont) - let fitsWidth = textSize.width <= availableWidth - let fitsHeight = textSize.height <= availableHeight - - if fitsWidth && fitsHeight { - bestSize = testSize - low = testSize + if textSize.width <= size.width && textSize.height <= size.height { + low = mid } else { - high = testSize + high = mid } } - return bestSize + return low + } + + private static func tightBoundingBox(for text: String, withFont font: UIFont) -> CGSize { + let attributedString = NSAttributedString( + string: text, + attributes: [.font: font] + ) + let rect = attributedString.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + return CGSize(width: ceil(rect.width), height: ceil(rect.height)) + } + + static func createUIFont(name: FontFamily, weight: Font.Weight, design: Font.Design, size: CGFloat) -> UIFont { + if name == .system { + return UIFont.systemFont(ofSize: size, weight: weight.uiFontWeight, width: design.uiFontWidth) + } + + if let font = UIFont(name: weightedFontName(name: name.rawValue, weight: weight, design: design), size: size) { + return font + } + + return UIFont.systemFont(ofSize: size, weight: weight.uiFontWeight, width: design.uiFontWidth) } /// Calculate AM/PM font size based on base font size @@ -106,313 +145,146 @@ enum FontUtils { return baseFontSize * 0.20 } - // MARK: - Font Customization + // MARK: - String Conversion Methods (for UserDefaults compatibility) - /// Convert font family string to Font + /// Convert font family string to FontFamily enum (for UserDefaults compatibility) /// - Parameter family: Font family name - /// - Returns: SwiftUI Font - static func fontFamily(_ family: String) -> Font { + /// - Returns: FontFamily enum + static func fontNameFromString(_ family: String) -> FontFamily { switch family { - case "System": - return .system(.body, design: .default) - case "Helvetica": - return .custom("Helvetica", size: 17) - case "Arial": - return .custom("Arial", size: 17) - case "Times New Roman": - return .custom("Times New Roman", size: 17) - case "Georgia": - return .custom("Georgia", size: 17) - case "Verdana": - return .custom("Verdana", size: 17) - case "Monaco": - return .system(.body, design: .monospaced) - case "Courier": - return .system(.body, design: .monospaced) - default: - return .system(.body, design: .default) + case "System": return .system + case "Helvetica": return .helvetica + case "Arial": return .arial + case "Times New Roman": return .timesNewRoman + case "Georgia": return .georgia + case "Verdana": return .verdana + case "Monaco", "Courier": return .courier + case "Futura": return .futura + case "Avenir": return .avenir + case "Roboto": return .roboto + default: return .system } } - /// Convert font weight string to Font.Weight + /// Convert font weight string to Font.Weight (for UserDefaults compatibility) /// - Parameter weight: Font weight name /// - Returns: Font.Weight - static func fontWeight(_ weight: String) -> Font.Weight { + static func fontWeightFromString(_ weight: String) -> Font.Weight { switch weight { - case "Ultra Light": - return .ultraLight - case "Thin": - return .thin - case "Light": - return .light - case "Regular": - return .regular - case "Medium": - return .medium - case "Semibold": - return .semibold - case "Bold": - return .bold - case "Heavy": - return .heavy - case "Black": - return .black - default: - return .bold + case "Ultra Light": return .ultraLight + case "Thin": return .thin + case "Light": return .light + case "Regular": return .regular + case "Medium": return .medium + case "Semibold": return .semibold + case "Bold": return .bold + case "Heavy": return .heavy + case "Black": return .black + default: return .bold } } - /// Convert font design string to Font.Design + /// Convert font design string to Font.Design (for UserDefaults compatibility) /// - Parameter design: Font design name /// - Returns: Font.Design - static func fontDesign(_ design: String) -> Font.Design { + static func fontDesignFromString(_ design: String) -> Font.Design { switch design { - case "Default": - return .default - case "Serif": - return .serif - case "Rounded": - return .rounded - case "Monospaced": - return .monospaced - default: - return .rounded + case "Default": return .default + case "Serif": return .serif + case "Rounded": return .rounded + case "Monospaced": return .monospaced + default: return .rounded } } - /// Create a custom font with specified parameters - /// - Parameters: - /// - size: Font size - /// - family: Font family name - /// - weight: Font weight name - /// - design: Font design name - /// - Returns: SwiftUI Font + /// Convert Font.Weight to string for storage + /// - Parameter weight: Font.Weight + /// - Returns: String representation + static func stringFromFontWeight(_ weight: Font.Weight) -> String { + switch weight { + case .ultraLight: return "Ultra Light" + case .thin: return "Thin" + case .light: return "Light" + case .regular: return "Regular" + case .medium: return "Medium" + case .semibold: return "Semibold" + case .bold: return "Bold" + case .heavy: return "Heavy" + case .black: return "Black" + default: return "Regular" + } + } + + /// Convert Font.Design to string for storage + /// - Parameter design: Font.Design + /// - Returns: String representation + static func stringFromFontDesign(_ design: Font.Design) -> String { + switch design { + case .default: return "Default" + case .serif: return "Serif" + case .rounded: return "Rounded" + case .monospaced: return "Monospaced" + @unknown default: return "Default" + } + } + + /// Time picker font size (legacy method) + static func timePickerFontSize( + containerWidth: CGFloat, + containerHeight: CGFloat, + isPortrait: Bool + ) -> CGFloat { + return 20 + } + + /// Dot size multiplier (legacy method) + static func dotSizeMultiplier(for fontWeight: Font.Weight) -> CGFloat { + return 0.2 + } + + /// Create a custom font with specified parameters (legacy method) static func customFont( size: CGFloat, family: String, weight: String, design: String ) -> Font { - // For system fonts, use the system font with design - if family == "System" || family == "Monaco" || family == "Courier" { - return .system( - size: size, - weight: fontWeight(weight), - design: fontDesign(design) - ) - } + let fontFamily = fontNameFromString(family) + let fontWeight = fontWeightFromString(weight) + let fontDesign = fontDesignFromString(design) - // For custom fonts, create with weight - let fontWeight = fontWeight(weight) - return .custom(family, size: size) - .weight(fontWeight) - } - - /// Get dot size multiplier to match visual weight of font - /// - Parameter weight: Font weight name - /// - Returns: Multiplier for dot size (0.3 to 1.0) - much smaller for lighter weights - static func dotSizeMultiplier(for weight: String) -> CGFloat { - switch weight { - case "Ultra Light": - return 0.3 - case "Thin": - return 0.35 - case "Light": - return 0.4 - case "Regular": - return 0.5 - case "Medium": - return 0.6 - case "Semibold": - return 0.7 - case "Bold": - return 0.8 - case "Heavy": - return 0.9 - case "Black": - return 1.0 - default: - return 0.7 + if fontFamily == .system { + return .system(size: size, weight: fontWeight, design: fontDesign) + } else { + return .custom(fontFamily.rawValue, size: size) } } - /// Create a UIFont with specified parameters for measurements - /// - Parameters: - /// - size: Font size - /// - family: Font family name - /// - weight: Font weight name - /// - design: Font design name - /// - Returns: UIFont + /// Create a custom UIFont with specified parameters (legacy method) static func customUIFont( size: CGFloat, family: String, weight: String, design: String ) -> UIFont { - let uiWeight: UIFont.Weight - switch weight { - case "Ultra Light": - uiWeight = .ultraLight - case "Thin": - uiWeight = .thin - case "Light": - uiWeight = .light - case "Regular": - uiWeight = .regular - case "Medium": - uiWeight = .medium - case "Semibold": - uiWeight = .semibold - case "Bold": - uiWeight = .bold - case "Heavy": - uiWeight = .heavy - case "Black": - uiWeight = .black - default: - uiWeight = .bold - } + let fontFamily = fontNameFromString(family) + let fontWeight = fontWeightFromString(weight) + let fontDesign = fontDesignFromString(design) - let uiDesign: UIFontDescriptor.SystemDesign - switch design { - case "Serif": - uiDesign = .serif - case "Rounded": - uiDesign = .rounded - case "Monospaced": - uiDesign = .monospaced - default: - uiDesign = .default - } - - // For system fonts, use system font descriptor with design - if family == "System" || family == "Monaco" || family == "Courier" { - let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - .withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) - - return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), size: size) - } - - // For custom fonts, create with family name and weight - return UIFont(name: family, size: size) ?? UIFont.systemFont(ofSize: size, weight: uiWeight) + return createUIFont(name: fontFamily, weight: fontWeight, design: fontDesign, size: size) } - /// Measure text size with given font - /// - Parameters: - /// - text: Text to measure - /// - font: Font to use for measurement - /// - Returns: Size of the text - static func measureTextSize(text: String, font: UIFont) -> CGSize { - let attributes = [NSAttributedString.Key.font: font] - return (text as NSString).size(withAttributes: attributes) - } - - // Calculate height of text for the given font - this ensures consistent height - static func calculateMaxTextSize(font: UIFont) -> CGSize { - let attributes = [NSAttributedString.Key.font: font] - let size = ("8" as NSString).size(withAttributes: attributes) - return size - } - - /// Calculate fixed-width layout size for time display to prevent jumping - /// - Parameters: - /// - font: The font to use for measurements - /// - showSeconds: Whether seconds are displayed - /// - showAmPm: Whether AM/PM is displayed - /// - isPortrait: Whether the device is in portrait orientation - /// - Returns: Total width and height needed for the layout - static func fixedWidthLayoutSize( - font: UIFont, - showSeconds: Bool, - showAmPm: Bool, - isPortrait: Bool - ) -> (width: CGFloat, height: CGFloat) { - let digitSize = calculateMaxTextSize(font: font) - let digitWidth = digitSize.width - let digitHeight = digitSize.height - - // Calculate separator sizes - let dotDiameter = font.pointSize * 0.20 - let hSpacing = font.pointSize * 0.18 - let vSpacing = font.pointSize * 0.22 - - if isPortrait { - // Portrait: vertical layout - let totalWidth = digitWidth * 2 // Two digits per row - let separatorHeight = dotDiameter * 2 + vSpacing - let contentHeight = digitHeight * (showSeconds ? 3 : 2) + separatorHeight - return (totalWidth, contentHeight) + /// Create a custom Font with enum parameters + static func customFont( + size: CGFloat, + family: FontFamily, + weight: Font.Weight, + design: Font.Design + ) -> Font { + if family == .system { + return .system(size: size, weight: weight, design: design) } else { - // Landscape: horizontal layout - let digitCount = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS - let separatorCount = showSeconds ? 2 : 1 - let separatorWidth = dotDiameter * 2 + hSpacing - let totalWidth = digitWidth * CGFloat(digitCount) + separatorWidth * CGFloat(separatorCount) - let totalHeight = digitHeight - return (totalWidth, totalHeight) + return .custom(family.rawValue, size: size) } } - - /// Calculate responsive font size for time picker components - /// - Parameters: - /// - containerWidth: Available container width - /// - containerHeight: Available container height - /// - isPortrait: Whether the device is in portrait orientation - /// - Returns: Font size optimized for time picker - static func timePickerFontSize( - containerWidth: CGFloat, - containerHeight: CGFloat, - isPortrait: Bool - ) -> CGFloat { - let safeInset = AppConstants.Defaults.safeInset - let availableWidth = max(1, containerWidth - safeInset * 2) - let availableHeight = max(1, containerHeight - safeInset * 2) - - // For time picker, we want larger, more readable fonts - let baseSize = isPortrait ? availableHeight * 0.15 : availableWidth * 0.08 - - // Apply bounds with higher minimum for readability - let minSize: CGFloat = 24 - let maxSize: CGFloat = 72 - return max(minSize, min(baseSize, maxSize)) - } - - /// Calculate maximum font size for stretched mode that uses visible space without overflow - /// - Parameters: - /// - containerWidth: Available container width - /// - containerHeight: Available container height - /// - isPortrait: Whether the device is in portrait orientation - /// - showSeconds: Whether seconds are displayed - /// - showAmPm: Whether AM/PM is displayed - /// - Returns: Maximum font size that fits within visible space - static func maximumStretchedFontSize( - containerWidth: CGFloat, - containerHeight: CGFloat, - isPortrait: Bool, - showSeconds: Bool = false - ) -> CGFloat { - // Calculate optimal size with reasonable space usage - let safeInset = AppConstants.Defaults.safeInset - let availableWidth = max(1, containerWidth - safeInset * 2) - let availableHeight = max(1, containerHeight - safeInset * 2) - - let optimalSize: CGFloat - if isPortrait { - // In portrait, use most of the available height - let contentHeight = availableHeight * 0.95 - let estimatedLines = showSeconds ? 3 : 2 - let lineHeight = contentHeight / CGFloat(estimatedLines) - optimalSize = lineHeight * 0.8 - } else { - // In landscape, use most of the available width - let contentWidth = availableWidth * 0.93 - let estimatedColumns = showSeconds ? 3 : 2 - let columnWidth = contentWidth / CGFloat(estimatedColumns) - optimalSize = columnWidth * 0.75 - } - - // Apply reasonable bounds - let minSize: CGFloat = AppConstants.Defaults.minFontSize - return max(minSize, optimalSize) - } } diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index 3b50390..d481798 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -38,9 +38,9 @@ class ClockStyle: Codable, Equatable { var ambientLightThreshold: Double = 0.3 // Threshold for ambient light detection (0.0-1.0) // MARK: - Font Settings - var fontFamily: String = "System" // System, San Francisco, etc. - var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black - var fontDesign: String = "Rounded" // Default, Serif, Rounded, Monospaced + var fontFamily: FontFamily = .system + var fontWeight: Font.Weight = .bold + var fontDesign: Font.Design = .rounded // MARK: - Overlay Settings var showBattery: Bool = true @@ -114,9 +114,16 @@ class ClockStyle: Codable, Equatable { self.nightModeEndTime = try container.decodeIfPresent(String.self, forKey: .nightModeEndTime) ?? self.nightModeEndTime self.autoBrightness = try container.decodeIfPresent(Bool.self, forKey: .autoBrightness) ?? self.autoBrightness self.ambientLightThreshold = try container.decodeIfPresent(Double.self, forKey: .ambientLightThreshold) ?? self.ambientLightThreshold - self.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily - self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight - self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign + // Decode font settings with fallback to string conversion + if let fontFamilyString = try container.decodeIfPresent(String.self, forKey: .fontFamily) { + self.fontFamily = FontUtils.fontNameFromString(fontFamilyString) + } + if let fontWeightString = try container.decodeIfPresent(String.self, forKey: .fontWeight) { + self.fontWeight = FontUtils.fontWeightFromString(fontWeightString) + } + if let fontDesignString = try container.decodeIfPresent(String.self, forKey: .fontDesign) { + self.fontDesign = FontUtils.fontDesignFromString(fontDesignString) + } self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat @@ -147,9 +154,9 @@ class ClockStyle: Codable, Equatable { try container.encode(nightModeEndTime, forKey: .nightModeEndTime) try container.encode(autoBrightness, forKey: .autoBrightness) try container.encode(ambientLightThreshold, forKey: .ambientLightThreshold) - try container.encode(fontFamily, forKey: .fontFamily) - try container.encode(fontWeight, forKey: .fontWeight) - try container.encode(fontDesign, forKey: .fontDesign) + try container.encode(fontFamily.rawValue, forKey: .fontFamily) + try container.encode(FontUtils.stringFromFontWeight(fontWeight), forKey: .fontWeight) + try container.encode(FontUtils.stringFromFontDesign(fontDesign), forKey: .fontDesign) try container.encode(showBattery, forKey: .showBattery) try container.encode(showDate, forKey: .showDate) try container.encode(dateFormat, forKey: .dateFormat) diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index 82f246c..bbe4c19 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -233,32 +233,30 @@ private struct AdvancedDisplaySection: View { private struct FontSection: View { @Binding var style: ClockStyle - private let fontFamilies = ["System", "Arial", "Courier", "Georgia", "Helvetica", "Monaco", "Times New Roman", "Verdana"] - private let fontWeights = ["Ultra Light", "Thin", "Light", "Regular", "Medium", "Semibold", "Bold", "Heavy", "Black"] - private let fontDesigns = ["Default", "Serif", "Rounded", "Monospaced"] + // Use the enum allCases for font options var body: some View { Section(header: Text("Font")) { // Font Family Picker("Family", selection: $style.fontFamily) { - ForEach(fontFamilies, id: \.self) { family in - Text(family).tag(family) + ForEach(FontFamily.allCases, id: \.self) { family in + Text(family.rawValue).tag(family) } } .pickerStyle(.menu) // Font Weight Picker("Weight", selection: $style.fontWeight) { - ForEach(fontWeights, id: \.self) { weight in - Text(weight).tag(weight) + ForEach(Font.Weight.allCases, id: \.self) { weight in + Text(FontUtils.stringFromFontWeight(weight)).tag(weight) } } .pickerStyle(.menu) // Font Design Picker("Design", selection: $style.fontDesign) { - ForEach(fontDesigns, id: \.self) { design in - Text(design).tag(design) + ForEach(Font.Design.allCases, id: \.self) { design in + Text(FontUtils.stringFromFontDesign(design)).tag(design) } } .pickerStyle(.menu) diff --git a/TheNoiseClock/Views/Clock/Components/DigitView.swift b/TheNoiseClock/Views/Clock/Components/DigitView.swift index a7ed7e3..8f9682d 100644 --- a/TheNoiseClock/Views/Clock/Components/DigitView.swift +++ b/TheNoiseClock/Views/Clock/Components/DigitView.swift @@ -9,14 +9,34 @@ import SwiftUI /// Component for displaying a single digit with fixed width and glow effects struct DigitView: View { - let digit: String - let fontSize: CGFloat - let opacity: Double - let digitColor: Color - let glowIntensity: Double - let fontFamily: String - let fontWeight: String - let fontDesign: String + @Environment(\.sizeCategory) private var sizeCategory + + @State var digit: String + @State var fontName: FontFamily + @State var weight: Font.Weight + @State var design: Font.Design + @State var opacity: Double + @State var digitColor: Color + @State var glowIntensity: Double + @Binding var fontSize: CGFloat + + init(digit: String, + fontName: FontFamily, + weight: Font.Weight = .regular, + design: Font.Design = .default, + digitColor: Color = .black, + opacity: Double = 1, + glowIntensity: Double = 0, + fontSize: Binding) { + self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0" + self.fontName = fontName + self.weight = weight + self.design = design + self.opacity = opacity + self.digitColor = digitColor + self.glowIntensity = glowIntensity + self._fontSize = fontSize + } var body: some View { GeometryReader { geometry in @@ -27,17 +47,6 @@ struct DigitView: View { .position(x: geometry.size.width / 2, y: geometry.size.height / 2) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Computed Properties - private var customFont: Font { - FontUtils.customFont( - size: fontSize, - family: fontFamily, - weight: fontWeight, - design: fontDesign - ) } private var glowRadius: CGFloat { @@ -49,35 +58,105 @@ struct DigitView: View { } private var glowText: some View { - Text(digit) - .font(customFont) + text .foregroundColor(digitColor) .blur(radius: glowRadius) .opacity(glowOpacity) } private var mainText: some View { - Text(digit) - .font(customFont) + text .foregroundColor(digitColor) .opacity(opacity) } + + private var text: some View { + GeometryReader { geometry in + Text(digit) + .font(.custom(fontName == .system ? "System" : fontName.rawValue, size: fontSize, relativeTo: .body).weight(weight)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.1) + .lineSpacing(0) + .padding(.vertical, 0) + .baselineOffset(0) + .onAppear { + let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit, + fontName: fontName, + weight: weight, + design: design, + for: geometry.size) + if optimalSize != fontSize { + fontSize = optimalSize + } + } + .onChange(of: geometry.size) { _, newSize in + let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit, + fontName: fontName, + weight: weight, + design: design, + for: newSize) + if optimalSize != fontSize { + fontSize = optimalSize + } + } + .onChange(of: sizeCategory) { _, _ in + let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit, + fontName: fontName, + weight: weight, + design: design, + for: geometry.size) + if optimalSize != fontSize { + fontSize = optimalSize + } + } + } + } } // MARK: - Preview #Preview { - let digitView = DigitView( - digit: "8", - fontSize: 80, - opacity: 1.0, - digitColor: .white, - glowIntensity: 0.2, - fontFamily: "System", - fontWeight: "Regular", - fontDesign: "Default" - ) + @Previewable @State var sharedFontSize: CGFloat = 2000 + @Previewable @State var fontName: FontFamily = .arial + @Previewable @State var weight: Font.Weight = .heavy + @Previewable @State var design: Font.Design = .rounded + @Previewable @State var glowIntensity: Double = 0.6 - return digitView - .background(Color.black) - .frame(width: 100, height: 120) + HStack { + DigitView(digit: "8", + fontName: fontName, + weight: weight, + design: design, + glowIntensity: glowIntensity, + fontSize: $sharedFontSize) + .border(Color.black) + + DigitView(digit: "1", + fontName: fontName, + weight: weight, + design: design, + glowIntensity: glowIntensity, + fontSize: $sharedFontSize) + .border(Color.black) + + Text(":") + .font(.system(size: sharedFontSize)) + .border(Color.black) + + DigitView(digit: "0", + fontName: fontName, + weight: weight, + design: design, + glowIntensity: glowIntensity, + fontSize: $sharedFontSize) + .border(Color.black) + + DigitView(digit: "5", + fontName: fontName, + weight: weight, + design: design, + glowIntensity: glowIntensity, + fontSize: $sharedFontSize) + .border(Color.black) + } } diff --git a/TheNoiseClock/Views/Clock/Components/DotCircle.swift b/TheNoiseClock/Views/Clock/Components/DotCircle.swift index 1d60445..fa78602 100644 --- a/TheNoiseClock/Views/Clock/Components/DotCircle.swift +++ b/TheNoiseClock/Views/Clock/Components/DotCircle.swift @@ -13,7 +13,7 @@ struct DotCircle: View { let opacity: Double let digitColor: Color let glowIntensity: Double - let fontWeight: String + let fontWeight: Font.Weight var body: some View { // Calculate size based on font weight - make dots smaller for lighter weights @@ -41,7 +41,7 @@ struct DotCircle: View { opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Light" + fontWeight: .light ) .background(Color.black) } @@ -52,7 +52,7 @@ struct DotCircle: View { opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Bold" + fontWeight: .bold ) .background(Color.black) } @@ -64,14 +64,14 @@ struct DotCircle: View { opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Regular" + fontWeight: .regular ) DotCircle( size: 10, opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Regular" + fontWeight: .regular ) } .background(Color.black) diff --git a/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift b/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift index e9cda3b..c5df478 100644 --- a/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift +++ b/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift @@ -14,7 +14,7 @@ struct HorizontalColon: View { let opacity: Double let digitColor: Color let glowIntensity: Double - let fontWeight: String + let fontWeight: Font.Weight var body: some View { let clamped = ColorUtils.clampOpacity(opacity) @@ -35,7 +35,7 @@ struct HorizontalColon: View { opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Regular" + fontWeight: .regular ) .background(Color.black) } diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift index 5440b08..3c0e341 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -19,10 +19,11 @@ struct TimeDisplayView: View { let manualScale: Double let stretched: Bool let clockOpacity: Double - let fontFamily: String - let fontWeight: String - let fontDesign: String + let fontFamily: FontFamily + let fontWeight: Font.Weight + let fontDesign: Font.Design let forceHorizontalMode: Bool + @State var fontSize: CGFloat = 1000 // MARK: - Formatters private static let hour24DF: DateFormatter = { @@ -60,22 +61,6 @@ struct TimeDisplayView: View { let portraitMode = containerSize.height >= containerSize.width let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width - // Use optimal font sizing based on actual container size - let baseFontSize = stretched ? - FontUtils.maximumStretchedFontSize( - containerWidth: containerSize.width, - containerHeight: containerSize.height, - isPortrait: portrait, - showSeconds: showSeconds - ) : - FontUtils.optimalFontSize( - containerWidth: containerSize.width, - containerHeight: containerSize.height, - isPortrait: portrait, - showSeconds: showSeconds - ) - - // Time components let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date) let minute = Self.minuteDF.string(from: date) @@ -83,9 +68,9 @@ struct TimeDisplayView: View { // Separators - reasonable spacing with extra padding in landscape - let dotDiameter = baseFontSize * 0.20 - let hSpacing = portrait ? baseFontSize * 0.18 : baseFontSize * 0.25 // More spacing in landscape - let vSpacing = portrait ? baseFontSize * 0.22 : baseFontSize * 0.30 // More spacing in landscape + let dotDiameter = fontSize * 0.20 + let hSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape + let vSpacing = portrait ? fontSize * 0.22 : fontSize * 0.30 // More spacing in landscape // Simple scaling - let the content size naturally and apply manual scale let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0))) @@ -93,23 +78,23 @@ struct TimeDisplayView: View { // Time display with consistent centering and stable layout Group { if portrait { - VStack(alignment: .center, spacing: 0) { - TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + VStack(alignment: .center) { + TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) - TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) if showSeconds { HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) - TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) } } } else { - HStack(alignment: .center, spacing: baseFontSize * 0.035) { - TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + HStack(alignment: .center) { + TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) - TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) if showSeconds { VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) - TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) + TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) } } .frame(maxWidth: .infinity) diff --git a/TheNoiseClock/Views/Clock/Components/TimeSegment.swift b/TheNoiseClock/Views/Clock/Components/TimeSegment.swift index b0a153e..6c03938 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeSegment.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeSegment.swift @@ -11,28 +11,27 @@ import Foundation /// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits struct TimeSegment: View { let text: String - let fontSize: CGFloat + @Binding var fontSize: CGFloat let opacity: Double let digitColor: Color let glowIntensity: Double - let fontFamily: String - let fontWeight: String - let fontDesign: String - + let fontFamily: FontFamily + let fontWeight: Font.Weight + let fontDesign: Font.Design + var body: some View { HStack(alignment: .center, spacing: 0) { ForEach(Array(text.enumerated()), id: \.offset) { index, character in DigitView( digit: String(character), - fontSize: fontSize, - opacity: clampedOpacity, + fontName: fontFamily, + weight: fontWeight, + design: fontDesign, digitColor: digitColor, + opacity: clampedOpacity, glowIntensity: glowIntensity, - fontFamily: fontFamily, - fontWeight: fontWeight, - fontDesign: fontDesign + fontSize: $fontSize ) - .frame(width: digitWidth) .border(.red, width: 1) } } @@ -44,43 +43,20 @@ struct TimeSegment: View { private var clampedOpacity: Double { ColorUtils.clampOpacity(opacity) } - - private var customFont: UIFont { - FontUtils.customUIFont( - size: fontSize, - family: fontFamily, - weight: fontWeight, - design: fontDesign - ) - } - - private var digitWidth: CGFloat { - // Calculate the actual width needed for a digit using font metrics - // This accounts for built-in font padding and ensures proper spacing - let font = customFont - let testString = "8" // Use a wide character to get maximum width - let attributes: [NSAttributedString.Key: Any] = [.font: font] - let size = testString.size(withAttributes: attributes) - return size.width + 4 // Add small padding for safety - } - - } // MARK: - Preview #Preview { - let segment = TimeSegment( + @Previewable @State var fontSize: CGFloat = 100 + TimeSegment( text: "12", - fontSize: 80, + fontSize: $fontSize, opacity: 1.0, digitColor: .white, glowIntensity: 0.2, - fontFamily: "System", - fontWeight: "Regular", - fontDesign: "Default" + fontFamily: .system, + fontWeight: .regular, + fontDesign: .default ) - - return segment - .background(Color.black) - .frame(width: 200, height: 100) + .background(Color.black) } diff --git a/TheNoiseClock/Views/Clock/Components/VerticalColon.swift b/TheNoiseClock/Views/Clock/Components/VerticalColon.swift index 95d9986..7599153 100644 --- a/TheNoiseClock/Views/Clock/Components/VerticalColon.swift +++ b/TheNoiseClock/Views/Clock/Components/VerticalColon.swift @@ -14,7 +14,7 @@ struct VerticalColon: View { let opacity: Double let digitColor: Color let glowIntensity: Double - let fontWeight: String + let fontWeight: Font.Weight var body: some View { let clamped = ColorUtils.clampOpacity(opacity) @@ -35,7 +35,7 @@ struct VerticalColon: View { opacity: 1.0, digitColor: .white, glowIntensity: 0.3, - fontWeight: "Regular" + fontWeight: .regular ) .background(Color.black) }