// // FontUtils.swift // TheNoiseClock // // Created by Matt Bruce on 9/7/25. // import SwiftUI /// Font sizing and typography utilities enum FontUtils { /// Calculate dynamic font size based on container dimensions /// - Parameters: /// - containerWidth: Container width /// - containerHeight: Container height /// - Returns: Calculated font size static func dynamicFontSize(containerWidth: CGFloat, containerHeight: CGFloat) -> CGFloat { let shortest = min(containerWidth, containerHeight) return min(shortest * 0.28, AppConstants.Defaults.maxFontSize) } /// 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, showAmPm: 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) // Estimate text content requirements (for future use) _ = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS _ = showAmPm ? 2 : 0 // AM/PM // 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 (reduced) let contentHeight = availableHeight - separatorHeight let estimatedLines = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM let lineHeight = contentHeight / CGFloat(estimatedLines) optimalSize = lineHeight * 0.85 // 85% of line height for actual text (increased) } else { // In landscape, be more aggressive with space usage // Account for separators and spacing let separatorWidth = availableWidth * 0.08 // 8% for separators (reduced) 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 (increased) } // 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) // Start with a reasonable base size let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15 // 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) let fitsWidth = textSize.width <= availableWidth let fitsHeight = textSize.height <= availableHeight if fitsWidth && fitsHeight { bestSize = testSize low = testSize } else { high = testSize } } return bestSize } /// Calculate AM/PM font size based on base font size /// - Parameter baseFontSize: Base font size /// - Returns: AM/PM font size (20% of base) static func ampmFontSize(baseFontSize: CGFloat) -> CGFloat { return baseFontSize * 0.20 } // MARK: - Font Customization /// Convert font family string to Font /// - Parameter family: Font family name /// - Returns: SwiftUI Font static func fontFamily(_ family: String) -> Font { switch family { case "San Francisco": return .system(.body, design: .default) case "System": return .system(.body, design: .default) case "Monaco": return .system(.body, design: .monospaced) case "Courier": return .system(.body, design: .monospaced) default: return .system(.body, design: .default) } } /// Convert font weight string to Font.Weight /// - Parameter weight: Font weight name /// - Returns: Font.Weight static func fontWeight(_ 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 } } /// Convert font design string to Font.Design /// - Parameter design: Font design name /// - Returns: Font.Design static func fontDesign(_ 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 } } /// 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 static func customFont( size: CGFloat, family: String, weight: String, design: String ) -> Font { return .system( size: size, weight: fontWeight(weight), design: fontDesign(design) ) } /// Calculate the maximum width for any two-digit combination /// - Parameters: /// - font: The font to measure with /// - fontSize: The font size /// - Returns: Maximum width for any two-digit combination static func maxTwoDigitWidth(font: UIFont, fontSize: CGFloat) -> CGFloat { let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] var maxWidth: CGFloat = 0 // Test all possible two-digit combinations to find the widest for firstDigit in digits { for secondDigit in digits { let combination = firstDigit + secondDigit let width = measureTextSize(text: combination, font: font).width maxWidth = max(maxWidth, width) } } return maxWidth } /// Get weight multiplier for visual consistency with font weight /// - Parameter weight: Font weight name /// - Returns: Multiplier for dot size (0.7 to 1.3) static func weightMultiplier(for weight: String) -> CGFloat { switch weight { case "Ultra Light": return 0.7 case "Thin": return 0.75 case "Light": return 0.8 case "Regular": return 0.85 case "Medium": return 0.9 case "Semibold": return 1.0 case "Bold": return 1.1 case "Heavy": return 1.2 case "Black": return 1.3 default: return 1.0 } } /// 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 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 uiDesign: UIFontDescriptor.SystemDesign switch design { case "Serif": uiDesign = .serif case "Rounded": uiDesign = .rounded case "Monospaced": uiDesign = .monospaced default: uiDesign = .default } let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) .withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), 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) } /// Get the maximum character width for digits to ensure consistent spacing /// - Parameter font: The font to measure with /// - Returns: The width of the widest digit character static func maxDigitWidth(font: UIFont) -> CGFloat { let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] var maxWidth: CGFloat = 0 for digit in digits { let size = measureTextSize(text: digit, font: font) maxWidth = max(maxWidth, size.width) } return maxWidth } /// 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 digitWidth = maxDigitWidth(font: font) let digitHeight = measureTextSize(text: "8", font: font).height // Use 8 as reference 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) } 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) } } /// 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, showAmPm: Bool = false ) -> CGFloat { // Use reasonable safe areas let safeInset = AppConstants.Defaults.safeInset let availableWidth = max(1, containerWidth - safeInset * 2) let availableHeight = max(1, containerHeight - safeInset * 2) // Calculate optimal size with reasonable space usage let optimalSize: CGFloat if isPortrait { // In portrait, use most of the available height let contentHeight = availableHeight * 0.85 // Use 85% of available height let estimatedLines = showSeconds ? 3 : 2 let lineHeight = contentHeight / CGFloat(estimatedLines) optimalSize = lineHeight * 0.8 // Use 80% of line height } else { // In landscape, use most of the available width let contentWidth = availableWidth * 0.85 // Use 85% of available width let estimatedColumns = showSeconds ? 3 : 2 let columnWidth = contentWidth / CGFloat(estimatedColumns) optimalSize = columnWidth * 0.7 // Use 70% of column width } // Apply reasonable bounds let minSize: CGFloat = 20 let maxSize: CGFloat = AppConstants.Defaults.maxFontSize return max(minSize, min(optimalSize, maxSize)) } }