font
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
c1128ac87a
commit
2fafae909d
12
PRD.md
12
PRD.md
@ -11,8 +11,10 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Optional seconds display** with toggle control
|
- **Optional seconds display** with toggle control
|
||||||
- **AM/PM badge** for 12-hour format (optional)
|
- **AM/PM badge** for 12-hour format (optional)
|
||||||
- **Segmented time display** with colon separators that adapt to orientation
|
- **Segmented time display** with colon separators that adapt to orientation
|
||||||
- **Dynamic scaling** that fits available screen space
|
- **Dynamic scaling** that maximizes available screen space usage
|
||||||
- **Portrait and landscape orientation support**
|
- **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
|
### 2. Clock Customization
|
||||||
- **Color customization**: User-selectable digit colors with color picker
|
- **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
|
### 6. Advanced Alarm System
|
||||||
- **Multiple alarms**: Create and manage unlimited alarms
|
- **Multiple alarms**: Create and manage unlimited alarms
|
||||||
- **Rich alarm editor**: Full-featured alarm creation and editing interface
|
- **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
|
- **Dynamic alarm sounds**: Configurable alarm sounds loaded from JSON configuration
|
||||||
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
- **Sound preview**: Play/stop functionality for testing alarm sounds before selection
|
||||||
- **Sound organization**: Alarm sounds organized in bundles with categories
|
- **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
|
- **Persistent storage**: Alarms saved to UserDefaults with backward compatibility
|
||||||
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
- **Alarm management**: Add, edit, delete, and duplicate alarms
|
||||||
- **Next trigger preview**: Shows when the next alarm will fire
|
- **Next trigger preview**: Shows when the next alarm will fire
|
||||||
|
- **Responsive time picker**: Font sizes adapt to available space and orientation
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
|
|
||||||
@ -194,9 +197,10 @@ TheNoiseClock/
|
|||||||
│ ├── Extensions/
|
│ ├── Extensions/
|
||||||
│ │ ├── Color+Extensions.swift # Color utilities and extensions
|
│ │ ├── Color+Extensions.swift # Color utilities and extensions
|
||||||
│ │ ├── Date+Extensions.swift # Date formatting and utilities
|
│ │ ├── Date+Extensions.swift # Date formatting and utilities
|
||||||
│ │ └── View+Extensions.swift # Common view modifiers
|
│ │ └── View+Extensions.swift # Common view modifiers and responsive utilities
|
||||||
│ └── Utilities/
|
│ └── Utilities/
|
||||||
│ ├── ColorUtils.swift # Color manipulation utilities
|
│ ├── ColorUtils.swift # Color manipulation utilities
|
||||||
|
│ ├── FontUtils.swift # Font sizing and typography utilities
|
||||||
│ └── NotificationUtils.swift # Notification helper functions
|
│ └── NotificationUtils.swift # Notification helper functions
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── ClockStyle.swift # Clock customization data model
|
│ ├── ClockStyle.swift # Clock customization data model
|
||||||
|
|||||||
@ -403,8 +403,7 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -435,8 +434,7 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@ -54,7 +54,7 @@ enum AppConstants {
|
|||||||
static let clockOpacity = 0.5
|
static let clockOpacity = 0.5
|
||||||
static let overlayOpacity = 0.5
|
static let overlayOpacity = 0.5
|
||||||
static let maxFontSize = 220.0
|
static let maxFontSize = 220.0
|
||||||
static let safeInset = 8.0
|
static let safeInset = 4.0 // Reasonable safe inset
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - System Sounds
|
// MARK: - System Sounds
|
||||||
|
|||||||
@ -56,6 +56,64 @@ extension View {
|
|||||||
tabBar.isUserInteractionEnabled = !hidden
|
tabBar.isUserInteractionEnabled = !hidden
|
||||||
#endif
|
#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)
|
#if canImport(UIKit)
|
||||||
|
|||||||
@ -31,20 +31,4 @@ enum ColorUtils {
|
|||||||
return max(0.0, min(opacity, 1.0))
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
252
TheNoiseClock/Core/Utilities/FontUtils.swift
Normal file
252
TheNoiseClock/Core/Utilities/FontUtils.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,22 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Time picker component for alarm creation
|
/// Time picker component for alarm creation with optimized font sizing
|
||||||
struct TimePickerSection: View {
|
struct TimePickerSection: View {
|
||||||
@Binding var selectedTime: Date
|
@Binding var selectedTime: Date
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
DatePicker(
|
DatePicker(
|
||||||
"Time",
|
"Time",
|
||||||
@ -20,8 +31,13 @@ struct TimePickerSection: View {
|
|||||||
)
|
)
|
||||||
.datePickerStyle(.wheel)
|
.datePickerStyle(.wheel)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(height: 200)
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,8 +62,24 @@ struct TimeDisplayView: View {
|
|||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
let size = proxy.size
|
let size = proxy.size
|
||||||
let portrait = size.height >= size.width
|
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
|
// Time components
|
||||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
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 ampmText = Self.ampmDF.string(from: date)
|
||||||
let showAMPM = !use24Hour && showAmPmBadge
|
let showAMPM = !use24Hour && showAmPmBadge
|
||||||
|
|
||||||
// Calculate sizes
|
// Calculate sizes using fixed-width approach to prevent jumping
|
||||||
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
|
||||||
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
|
||||||
let hourSize = measureText(hour, font: digitUIFont)
|
|
||||||
let minuteSize = measureText(minute, font: digitUIFont)
|
// Use fixed-width calculations to prevent layout jumping
|
||||||
let secondsSize = showSeconds ? measureText(secondsText, font: digitUIFont) : .zero
|
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
|
let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero
|
||||||
|
|
||||||
// Separators
|
// Separators - reasonable spacing
|
||||||
let dotDiameter = baseFontSize * 0.20
|
let dotDiameter = baseFontSize * 0.20
|
||||||
let hSpacing = baseFontSize * 0.18
|
let hSpacing = baseFontSize * 0.18
|
||||||
let vSpacing = baseFontSize * 0.22
|
let vSpacing = baseFontSize * 0.22
|
||||||
@ -100,17 +122,23 @@ struct TimeDisplayView: View {
|
|||||||
showAMPM: showAMPM
|
showAMPM: showAMPM
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculate scale
|
// Calculate scale with maximum space utilization
|
||||||
let safeInset = AppConstants.Defaults.safeInset
|
let safeInset = AppConstants.Defaults.safeInset
|
||||||
let availableW = max(1, size.width - safeInset * 2)
|
let availableW = max(1, size.width - safeInset * 2)
|
||||||
let availableH = max(1, size.height - safeInset * 2)
|
let availableH = max(1, size.height - safeInset * 2)
|
||||||
|
|
||||||
|
// Calculate scaling factors
|
||||||
let widthScale = availableW / max(totalWidth, 1)
|
let widthScale = availableW / max(totalWidth, 1)
|
||||||
let heightScale = availableH / max(totalHeight, 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 {
|
Group {
|
||||||
if portrait {
|
if portrait {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@ -124,6 +152,10 @@ struct TimeDisplayView: View {
|
|||||||
if showSeconds {
|
if showSeconds {
|
||||||
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
TimeSegment(text: secondsText, fontSize: baseFontSize, 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 {
|
} else {
|
||||||
@ -138,22 +170,34 @@ struct TimeDisplayView: View {
|
|||||||
if showSeconds {
|
if showSeconds {
|
||||||
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
|
||||||
TimeSegment(text: secondsText, fontSize: baseFontSize, 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)
|
.frame(width: size.width, height: size.height, alignment: .center)
|
||||||
.scaleEffect(effectiveScale, anchor: .center)
|
.scaleEffect(finalScale, anchor: .center)
|
||||||
.animation(UIConstants.AnimationCurves.smooth, value: effectiveScale)
|
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
|
||||||
|
.animation(UIConstants.AnimationCurves.smooth, value: showSeconds) // Smooth animation for seconds toggle
|
||||||
.minimumScaleFactor(0.1)
|
.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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onOrientationChange() // Force updates on orientation changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
private func measureText(_ text: String, font: UIFont) -> CGSize {
|
private func measureText(_ text: String, font: UIFont) -> CGSize {
|
||||||
let attributes = [NSAttributedString.Key.font: font]
|
return FontUtils.measureTextSize(text: text, font: font)
|
||||||
return (text as NSString).size(withAttributes: attributes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateLayoutSize(
|
private func calculateLayoutSize(
|
||||||
@ -217,6 +261,9 @@ private struct TimeSegment: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let clamped = ColorUtils.clampOpacity(opacity)
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
|
||||||
|
let maxWidth = FontUtils.maxDigitWidth(font: font)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||||
@ -228,9 +275,10 @@ private struct TimeSegment: View {
|
|||||||
.foregroundColor(digitColor)
|
.foregroundColor(digitColor)
|
||||||
.opacity(clamped)
|
.opacity(clamped)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
.frame(width: maxWidth * CGFloat(text.count), height: nil, alignment: .center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.allowsTightening(true)
|
.allowsTightening(false) // Prevent tightening to maintain fixed width
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user