From 2fafae909dc1a4fec0cd2a552acba0d97e14746e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 8 Sep 2025 10:41:51 -0500 Subject: [PATCH] font Signed-off-by: Matt Bruce --- PRD.md | 12 +- TheNoiseClock.xcodeproj/project.pbxproj | 6 +- .../Core/Constants/AppConstants.swift | 2 +- .../Core/Extensions/View+Extensions.swift | 58 ++++ TheNoiseClock/Core/Utilities/ColorUtils.swift | 16 -- TheNoiseClock/Core/Utilities/FontUtils.swift | 252 ++++++++++++++++++ .../Alarms/Components/TimePickerSection.swift | 36 ++- .../Clock/Components/TimeDisplayView.swift | 84 ++++-- 8 files changed, 413 insertions(+), 53 deletions(-) create mode 100644 TheNoiseClock/Core/Utilities/FontUtils.swift diff --git a/PRD.md b/PRD.md index e3d8cef..82a9896 100644 --- a/PRD.md +++ b/PRD.md @@ -11,8 +11,10 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Optional seconds display** with toggle control - **AM/PM badge** for 12-hour format (optional) - **Segmented time display** with colon separators that adapt to orientation -- **Dynamic scaling** that fits available screen space -- **Portrait and landscape orientation support** +- **Dynamic scaling** that maximizes available screen space usage +- **Portrait and landscape orientation support** with responsive font sizing +- **Optimal font sizing** that uses all available space efficiently +- **Immediate updates** on orientation changes and tab bar visibility changes ### 2. Clock Customization - **Color customization**: User-selectable digit colors with color picker @@ -50,7 +52,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di ### 6. Advanced Alarm System - **Multiple alarms**: Create and manage unlimited alarms - **Rich alarm editor**: Full-featured alarm creation and editing interface -- **Time selection**: Wheel-style date picker for precise alarm time +- **Time selection**: Wheel-style date picker with optimized font sizing for maximum readability - **Dynamic alarm sounds**: Configurable alarm sounds loaded from JSON configuration - **Sound preview**: Play/stop functionality for testing alarm sounds before selection - **Sound organization**: Alarm sounds organized in bundles with categories @@ -66,6 +68,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di - **Persistent storage**: Alarms saved to UserDefaults with backward compatibility - **Alarm management**: Add, edit, delete, and duplicate alarms - **Next trigger preview**: Shows when the next alarm will fire +- **Responsive time picker**: Font sizes adapt to available space and orientation ## Technical Architecture @@ -194,9 +197,10 @@ TheNoiseClock/ │ ├── Extensions/ │ │ ├── Color+Extensions.swift # Color utilities and extensions │ │ ├── Date+Extensions.swift # Date formatting and utilities -│ │ └── View+Extensions.swift # Common view modifiers +│ │ └── View+Extensions.swift # Common view modifiers and responsive utilities │ └── Utilities/ │ ├── ColorUtils.swift # Color manipulation utilities +│ ├── FontUtils.swift # Font sizing and typography utilities │ └── NotificationUtils.swift # Notification helper functions ├── Models/ │ ├── ClockStyle.swift # Clock customization data model diff --git a/TheNoiseClock.xcodeproj/project.pbxproj b/TheNoiseClock.xcodeproj/project.pbxproj index 9485d98..fb2a37c 100644 --- a/TheNoiseClock.xcodeproj/project.pbxproj +++ b/TheNoiseClock.xcodeproj/project.pbxproj @@ -403,8 +403,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -435,8 +434,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/TheNoiseClock/Core/Constants/AppConstants.swift b/TheNoiseClock/Core/Constants/AppConstants.swift index 30a52fd..bf7faaa 100644 --- a/TheNoiseClock/Core/Constants/AppConstants.swift +++ b/TheNoiseClock/Core/Constants/AppConstants.swift @@ -54,7 +54,7 @@ enum AppConstants { static let clockOpacity = 0.5 static let overlayOpacity = 0.5 static let maxFontSize = 220.0 - static let safeInset = 8.0 + static let safeInset = 4.0 // Reasonable safe inset } // MARK: - System Sounds diff --git a/TheNoiseClock/Core/Extensions/View+Extensions.swift b/TheNoiseClock/Core/Extensions/View+Extensions.swift index 698b018..007c36e 100644 --- a/TheNoiseClock/Core/Extensions/View+Extensions.swift +++ b/TheNoiseClock/Core/Extensions/View+Extensions.swift @@ -56,6 +56,64 @@ extension View { tabBar.isUserInteractionEnabled = !hidden #endif } + + /// Apply responsive font sizing that updates on orientation and layout changes + /// - Parameters: + /// - baseSize: Base font size + /// - isPortrait: Whether in portrait orientation + /// - showSeconds: Whether seconds are displayed (for time components) + /// - showAmPm: Whether AM/PM is displayed + /// - Returns: View with responsive font sizing + func responsiveFontSize( + baseSize: CGFloat, + isPortrait: Bool, + showSeconds: Bool = false, + showAmPm: Bool = false + ) -> some View { + self.modifier(ResponsiveFontModifier( + baseSize: baseSize, + isPortrait: isPortrait, + showSeconds: showSeconds, + showAmPm: showAmPm + )) + } + + /// Force view to update on orientation changes + /// - Returns: View that updates on orientation changes + func onOrientationChange() -> some View { + self.modifier(OrientationChangeModifier()) + } +} + +// MARK: - View Modifiers + +/// Modifier for responsive font sizing that updates on layout changes +struct ResponsiveFontModifier: ViewModifier { + let baseSize: CGFloat + let isPortrait: Bool + let showSeconds: Bool + let showAmPm: Bool + + func body(content: Content) -> some View { + content + .font(.system(size: baseSize, weight: .bold, design: .rounded)) + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + // Force view update on orientation change + } + } +} + +/// Modifier that forces view updates on orientation changes +struct OrientationChangeModifier: ViewModifier { + @State private var orientation = UIDevice.current.orientation + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + orientation = UIDevice.current.orientation + } + .id(orientation.rawValue) // Force view recreation on orientation change + } } #if canImport(UIKit) diff --git a/TheNoiseClock/Core/Utilities/ColorUtils.swift b/TheNoiseClock/Core/Utilities/ColorUtils.swift index 7c29929..9ecf94c 100644 --- a/TheNoiseClock/Core/Utilities/ColorUtils.swift +++ b/TheNoiseClock/Core/Utilities/ColorUtils.swift @@ -31,20 +31,4 @@ enum ColorUtils { return max(0.0, min(opacity, 1.0)) } - /// 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 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 - } } diff --git a/TheNoiseClock/Core/Utilities/FontUtils.swift b/TheNoiseClock/Core/Utilities/FontUtils.swift new file mode 100644 index 0000000..92b9ad5 --- /dev/null +++ b/TheNoiseClock/Core/Utilities/FontUtils.swift @@ -0,0 +1,252 @@ +// +// 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 + } + + /// 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)) + } +} diff --git a/TheNoiseClock/Views/Alarms/Components/TimePickerSection.swift b/TheNoiseClock/Views/Alarms/Components/TimePickerSection.swift index cb34d76..cb81d43 100644 --- a/TheNoiseClock/Views/Alarms/Components/TimePickerSection.swift +++ b/TheNoiseClock/Views/Alarms/Components/TimePickerSection.swift @@ -7,21 +7,37 @@ import SwiftUI -/// Time picker component for alarm creation +/// Time picker component for alarm creation with optimized font sizing struct TimePickerSection: View { @Binding var selectedTime: Date var body: some View { - VStack(spacing: 0) { - DatePicker( - "Time", - selection: $selectedTime, - displayedComponents: .hourAndMinute + GeometryReader { proxy in + let size = proxy.size + let portrait = size.height >= size.width + + // Calculate optimal font size for time picker + let pickerFontSize = FontUtils.timePickerFontSize( + containerWidth: size.width, + containerHeight: size.height, + isPortrait: portrait ) - .datePickerStyle(.wheel) - .labelsHidden() - .frame(height: 200) + + VStack(spacing: 0) { + DatePicker( + "Time", + selection: $selectedTime, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.wheel) + .labelsHidden() + .font(.system(size: pickerFontSize, weight: .medium, design: .rounded)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + .background(Color(.systemGroupedBackground)) } - .background(Color(.systemGroupedBackground)) + .frame(height: 200) + .onOrientationChange() // Force updates on orientation changes } } diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift index d3d813e..a2ec8a6 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -62,8 +62,24 @@ struct TimeDisplayView: View { GeometryReader { proxy in let size = proxy.size let portrait = size.height >= size.width - let baseFontSize = ColorUtils.dynamicFontSize(containerWidth: size.width, containerHeight: size.height) - let ampmFontSize = ColorUtils.ampmFontSize(baseFontSize: baseFontSize) + + // Use optimal font sizing that maximizes space usage + let baseFontSize = stretched ? + FontUtils.maximumStretchedFontSize( + containerWidth: size.width, + containerHeight: size.height, + isPortrait: portrait, + showSeconds: showSeconds, + showAmPm: !use24Hour && showAmPmBadge + ) : + FontUtils.optimalFontSize( + containerWidth: size.width, + containerHeight: size.height, + isPortrait: portrait, + showSeconds: showSeconds, + showAmPm: !use24Hour && showAmPmBadge + ) + let ampmFontSize = FontUtils.ampmFontSize(baseFontSize: baseFontSize) // Time components let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date) @@ -72,15 +88,21 @@ struct TimeDisplayView: View { let ampmText = Self.ampmDF.string(from: date) let showAMPM = !use24Hour && showAmPmBadge - // Calculate sizes + // Calculate sizes using fixed-width approach to prevent jumping let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold) let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold) - let hourSize = measureText(hour, font: digitUIFont) - let minuteSize = measureText(minute, font: digitUIFont) - let secondsSize = showSeconds ? measureText(secondsText, font: digitUIFont) : .zero + + // Use fixed-width calculations to prevent layout jumping + let digitWidth = FontUtils.maxDigitWidth(font: digitUIFont) + let digitHeight = measureText("8", font: digitUIFont).height // Use 8 as reference height + + // Fixed sizes for consistent layout + let hourSize = CGSize(width: digitWidth * 2, height: digitHeight) // Two digits + let minuteSize = CGSize(width: digitWidth * 2, height: digitHeight) // Two digits + let secondsSize = showSeconds ? CGSize(width: digitWidth * 2, height: digitHeight) : .zero let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero - // Separators + // Separators - reasonable spacing let dotDiameter = baseFontSize * 0.20 let hSpacing = baseFontSize * 0.18 let vSpacing = baseFontSize * 0.22 @@ -100,17 +122,23 @@ struct TimeDisplayView: View { showAMPM: showAMPM ) - // Calculate scale + // Calculate scale with maximum space utilization let safeInset = AppConstants.Defaults.safeInset let availableW = max(1, size.width - safeInset * 2) let availableH = max(1, size.height - safeInset * 2) + + // Calculate scaling factors let widthScale = availableW / max(totalWidth, 1) let heightScale = availableH / max(totalHeight, 1) - let fittedScale = max(0.1, min(widthScale, heightScale)) - let manualPercent = max(0.0, min(manualScale, 1.0)) - let effectiveScale = stretched ? fittedScale : max(0.05, fittedScale * CGFloat(manualPercent)) - // Time display + // For stretched mode, use reasonable scaling + let effectiveScale = stretched ? + max(0.1, min(min(widthScale, heightScale), 1.5)) : // Use min scale and cap at 1.5x to prevent overflow + max(0.1, max(0.1, min(widthScale, heightScale)) * CGFloat(max(0.1, min(manualScale, 1.0)))) + + let finalScale = effectiveScale + + // Time display with consistent centering and stable layout Group { if portrait { VStack(spacing: 0) { @@ -124,6 +152,10 @@ struct TimeDisplayView: View { if showSeconds { HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + } else { + // Invisible placeholder to maintain consistent spacing + Spacer() + .frame(height: baseFontSize * 0.3) } } } else { @@ -138,22 +170,34 @@ struct TimeDisplayView: View { if showSeconds { VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + } else { + // Invisible placeholder to maintain consistent spacing + Spacer() + .frame(width: baseFontSize * 0.3) } } } } .frame(width: size.width, height: size.height, alignment: .center) - .scaleEffect(effectiveScale, anchor: .center) - .animation(UIConstants.AnimationCurves.smooth, value: effectiveScale) + .scaleEffect(finalScale, anchor: .center) + .animation(UIConstants.AnimationCurves.smooth, value: finalScale) + .animation(UIConstants.AnimationCurves.smooth, value: showSeconds) // Smooth animation for seconds toggle .minimumScaleFactor(0.1) + .clipped() // Prevent overflow beyond bounds + .overlay( + // Debug border to visualize actual bounds + Rectangle() + .stroke(Color.red, lineWidth: 2) + .opacity(0.5) + ) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onOrientationChange() // Force updates on orientation changes } // MARK: - Helper Methods private func measureText(_ text: String, font: UIFont) -> CGSize { - let attributes = [NSAttributedString.Key.font: font] - return (text as NSString).size(withAttributes: attributes) + return FontUtils.measureTextSize(text: text, font: font) } private func calculateLayoutSize( @@ -217,6 +261,9 @@ private struct TimeSegment: View { var body: some View { let clamped = ColorUtils.clampOpacity(opacity) + let font = UIFont.systemFont(ofSize: fontSize, weight: .bold) + let maxWidth = FontUtils.maxDigitWidth(font: font) + ZStack { Text(text) .font(.system(size: fontSize, weight: .bold, design: .rounded)) @@ -228,9 +275,10 @@ private struct TimeSegment: View { .foregroundColor(digitColor) .opacity(clamped) } - .fixedSize(horizontal: true, vertical: true) + .frame(width: maxWidth * CGFloat(text.count), height: nil, alignment: .center) + .fixedSize(horizontal: false, vertical: true) .lineLimit(1) - .allowsTightening(true) + .allowsTightening(false) // Prevent tightening to maintain fixed width .multilineTextAlignment(.center) } }