Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-11 10:25:55 -05:00
parent 5b2e321fb8
commit eafafcb3ab
9 changed files with 399 additions and 482 deletions

View File

@ -5,98 +5,137 @@
// Created by Matt Bruce on 9/7/25. // Created by Matt Bruce on 9/7/25.
// //
import Foundation
import UIKit
import SwiftUI import SwiftUI
/// Font sizing and typography utilities enum FontFamily: String, CaseIterable {
enum FontUtils { 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 extension Font.Weight {
/// - Parameters: static var allCases: [Font.Weight] {
/// - containerWidth: Available container width [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
/// - containerHeight: Available container height }
/// - isPortrait: Whether the device is in portrait orientation var uiFontWeight: UIFont.Weight {
/// - showSeconds: Whether seconds are displayed switch self {
/// - showAmPm: Whether AM/PM is displayed case .ultraLight: return .ultraLight
/// - Returns: Optimal font size for maximum space utilization case .thin: return .thin
static func optimalFontSize( case .light: return .light
containerWidth: CGFloat, case .regular: return .regular
containerHeight: CGFloat, case .medium: return .medium
isPortrait: Bool, case .semibold: return .semibold
showSeconds: Bool = false case .bold: return .bold
) -> CGFloat { case .heavy: return .heavy
// Account for safe areas and padding case .black: return .black
let safeInset = AppConstants.Defaults.safeInset default: return .regular
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)
} }
// 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 var uiFontWeightSuffix: String {
/// - Parameters: switch self {
/// - containerWidth: Available container width case .ultraLight: return "-UltraLight"
/// - containerHeight: Available container height case .thin: return "-Thin"
/// - textContent: The actual text content to measure case .light: return "-Light"
/// - isPortrait: Whether the device is in portrait orientation case .regular: return ""
/// - Returns: Font size that maximizes space usage case .medium: return "-Medium"
static func fillSpaceFontSize( case .semibold: return "-SemiBold"
containerWidth: CGFloat, case .bold: return "-Bold"
containerHeight: CGFloat, case .heavy: return "-Heavy"
textContent: String, case .black: return "-Black"
isPortrait: Bool default: return ""
) -> 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 extension Font.Design {
let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15 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
}
}
}
// Binary search for optimal size /// Font sizing and typography utilities
var low: CGFloat = 10 struct FontUtils {
var high: CGFloat = AppConstants.Defaults.maxFontSize
var bestSize: CGFloat = baseSize
for _ in 0..<10 { // Limit iterations static func weightedFontName(name: String, weight: Font.Weight, design: Font.Design) -> String {
let testSize = (low + high) / 2 let weightSuffix = weight.uiFontWeightSuffix
let font = UIFont.systemFont(ofSize: testSize, weight: .bold)
let textSize = measureTextSize(text: textContent, font: font)
let fitsWidth = textSize.width <= availableWidth switch design {
let fitsHeight = textSize.height <= availableHeight 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
}
}
if fitsWidth && fitsHeight { static func calculateOptimalFontSize(digit: String, fontName: FontFamily, weight: Font.Weight, design: Font.Design, for size: CGSize) -> CGFloat {
bestSize = testSize var low: CGFloat = 1.0
low = testSize var high: CGFloat = 2000.0
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)
if textSize.width <= size.width && textSize.height <= size.height {
low = mid
} else { } 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 /// Calculate AM/PM font size based on base font size
@ -106,313 +145,146 @@ enum FontUtils {
return baseFontSize * 0.20 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 /// - Parameter family: Font family name
/// - Returns: SwiftUI Font /// - Returns: FontFamily enum
static func fontFamily(_ family: String) -> Font { static func fontNameFromString(_ family: String) -> FontFamily {
switch family { switch family {
case "System": case "System": return .system
return .system(.body, design: .default) case "Helvetica": return .helvetica
case "Helvetica": case "Arial": return .arial
return .custom("Helvetica", size: 17) case "Times New Roman": return .timesNewRoman
case "Arial": case "Georgia": return .georgia
return .custom("Arial", size: 17) case "Verdana": return .verdana
case "Times New Roman": case "Monaco", "Courier": return .courier
return .custom("Times New Roman", size: 17) case "Futura": return .futura
case "Georgia": case "Avenir": return .avenir
return .custom("Georgia", size: 17) case "Roboto": return .roboto
case "Verdana": default: return .system
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)
} }
} }
/// Convert font weight string to Font.Weight /// Convert font weight string to Font.Weight (for UserDefaults compatibility)
/// - Parameter weight: Font weight name /// - Parameter weight: Font weight name
/// - Returns: Font.Weight /// - Returns: Font.Weight
static func fontWeight(_ weight: String) -> Font.Weight { static func fontWeightFromString(_ weight: String) -> Font.Weight {
switch weight { switch weight {
case "Ultra Light": case "Ultra Light": return .ultraLight
return .ultraLight case "Thin": return .thin
case "Thin": case "Light": return .light
return .thin case "Regular": return .regular
case "Light": case "Medium": return .medium
return .light case "Semibold": return .semibold
case "Regular": case "Bold": return .bold
return .regular case "Heavy": return .heavy
case "Medium": case "Black": return .black
return .medium default: return .bold
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 /// - Parameter design: Font design name
/// - Returns: Font.Design /// - Returns: Font.Design
static func fontDesign(_ design: String) -> Font.Design { static func fontDesignFromString(_ design: String) -> Font.Design {
switch design { switch design {
case "Default": case "Default": return .default
return .default case "Serif": return .serif
case "Serif": case "Rounded": return .rounded
return .serif case "Monospaced": return .monospaced
case "Rounded": default: return .rounded
return .rounded
case "Monospaced":
return .monospaced
default:
return .rounded
} }
} }
/// Create a custom font with specified parameters /// Convert Font.Weight to string for storage
/// - Parameters: /// - Parameter weight: Font.Weight
/// - size: Font size /// - Returns: String representation
/// - family: Font family name static func stringFromFontWeight(_ weight: Font.Weight) -> String {
/// - weight: Font weight name switch weight {
/// - design: Font design name case .ultraLight: return "Ultra Light"
/// - Returns: SwiftUI Font 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( static func customFont(
size: CGFloat, size: CGFloat,
family: String, family: String,
weight: String, weight: String,
design: String design: String
) -> Font { ) -> Font {
// For system fonts, use the system font with design let fontFamily = fontNameFromString(family)
if family == "System" || family == "Monaco" || family == "Courier" { let fontWeight = fontWeightFromString(weight)
return .system( let fontDesign = fontDesignFromString(design)
size: size,
weight: fontWeight(weight),
design: fontDesign(design)
)
}
// For custom fonts, create with weight if fontFamily == .system {
let fontWeight = fontWeight(weight) return .system(size: size, weight: fontWeight, design: fontDesign)
return .custom(family, size: size) } else {
.weight(fontWeight) return .custom(fontFamily.rawValue, size: size)
}
/// 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
} }
} }
/// Create a UIFont with specified parameters for measurements /// Create a custom UIFont with specified parameters (legacy method)
/// - Parameters:
/// - size: Font size
/// - family: Font family name
/// - weight: Font weight name
/// - design: Font design name
/// - Returns: UIFont
static func customUIFont( static func customUIFont(
size: CGFloat, size: CGFloat,
family: String, family: String,
weight: String, weight: String,
design: String design: String
) -> UIFont { ) -> UIFont {
let uiWeight: UIFont.Weight let fontFamily = fontNameFromString(family)
switch weight { let fontWeight = fontWeightFromString(weight)
case "Ultra Light": let fontDesign = fontDesignFromString(design)
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 return createUIFont(name: fontFamily, weight: fontWeight, design: fontDesign, size: size)
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)
} }
/// Measure text size with given font /// Create a custom Font with enum parameters
/// - Parameters: static func customFont(
/// - text: Text to measure size: CGFloat,
/// - font: Font to use for measurement family: FontFamily,
/// - Returns: Size of the text weight: Font.Weight,
static func measureTextSize(text: String, font: UIFont) -> CGSize { design: Font.Design
let attributes = [NSAttributedString.Key.font: font] ) -> Font {
return (text as NSString).size(withAttributes: attributes) if family == .system {
} return .system(size: size, weight: weight, design: design)
// 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)
} else { } else {
// Landscape: horizontal layout return .custom(family.rawValue, size: size)
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
) -> 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)
}
} }

View File

@ -38,9 +38,9 @@ class ClockStyle: Codable, Equatable {
var ambientLightThreshold: Double = 0.3 // Threshold for ambient light detection (0.0-1.0) var ambientLightThreshold: Double = 0.3 // Threshold for ambient light detection (0.0-1.0)
// MARK: - Font Settings // MARK: - Font Settings
var fontFamily: String = "System" // System, San Francisco, etc. var fontFamily: FontFamily = .system
var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black var fontWeight: Font.Weight = .bold
var fontDesign: String = "Rounded" // Default, Serif, Rounded, Monospaced var fontDesign: Font.Design = .rounded
// MARK: - Overlay Settings // MARK: - Overlay Settings
var showBattery: Bool = true var showBattery: Bool = true
@ -114,9 +114,16 @@ class ClockStyle: Codable, Equatable {
self.nightModeEndTime = try container.decodeIfPresent(String.self, forKey: .nightModeEndTime) ?? self.nightModeEndTime self.nightModeEndTime = try container.decodeIfPresent(String.self, forKey: .nightModeEndTime) ?? self.nightModeEndTime
self.autoBrightness = try container.decodeIfPresent(Bool.self, forKey: .autoBrightness) ?? self.autoBrightness self.autoBrightness = try container.decodeIfPresent(Bool.self, forKey: .autoBrightness) ?? self.autoBrightness
self.ambientLightThreshold = try container.decodeIfPresent(Double.self, forKey: .ambientLightThreshold) ?? self.ambientLightThreshold self.ambientLightThreshold = try container.decodeIfPresent(Double.self, forKey: .ambientLightThreshold) ?? self.ambientLightThreshold
self.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily // Decode font settings with fallback to string conversion
self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight if let fontFamilyString = try container.decodeIfPresent(String.self, forKey: .fontFamily) {
self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign 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.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat 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(nightModeEndTime, forKey: .nightModeEndTime)
try container.encode(autoBrightness, forKey: .autoBrightness) try container.encode(autoBrightness, forKey: .autoBrightness)
try container.encode(ambientLightThreshold, forKey: .ambientLightThreshold) try container.encode(ambientLightThreshold, forKey: .ambientLightThreshold)
try container.encode(fontFamily, forKey: .fontFamily) try container.encode(fontFamily.rawValue, forKey: .fontFamily)
try container.encode(fontWeight, forKey: .fontWeight) try container.encode(FontUtils.stringFromFontWeight(fontWeight), forKey: .fontWeight)
try container.encode(fontDesign, forKey: .fontDesign) try container.encode(FontUtils.stringFromFontDesign(fontDesign), forKey: .fontDesign)
try container.encode(showBattery, forKey: .showBattery) try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate) try container.encode(showDate, forKey: .showDate)
try container.encode(dateFormat, forKey: .dateFormat) try container.encode(dateFormat, forKey: .dateFormat)

View File

@ -233,32 +233,30 @@ private struct AdvancedDisplaySection: View {
private struct FontSection: View { private struct FontSection: View {
@Binding var style: ClockStyle @Binding var style: ClockStyle
private let fontFamilies = ["System", "Arial", "Courier", "Georgia", "Helvetica", "Monaco", "Times New Roman", "Verdana"] // Use the enum allCases for font options
private let fontWeights = ["Ultra Light", "Thin", "Light", "Regular", "Medium", "Semibold", "Bold", "Heavy", "Black"]
private let fontDesigns = ["Default", "Serif", "Rounded", "Monospaced"]
var body: some View { var body: some View {
Section(header: Text("Font")) { Section(header: Text("Font")) {
// Font Family // Font Family
Picker("Family", selection: $style.fontFamily) { Picker("Family", selection: $style.fontFamily) {
ForEach(fontFamilies, id: \.self) { family in ForEach(FontFamily.allCases, id: \.self) { family in
Text(family).tag(family) Text(family.rawValue).tag(family)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
// Font Weight // Font Weight
Picker("Weight", selection: $style.fontWeight) { Picker("Weight", selection: $style.fontWeight) {
ForEach(fontWeights, id: \.self) { weight in ForEach(Font.Weight.allCases, id: \.self) { weight in
Text(weight).tag(weight) Text(FontUtils.stringFromFontWeight(weight)).tag(weight)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
// Font Design // Font Design
Picker("Design", selection: $style.fontDesign) { Picker("Design", selection: $style.fontDesign) {
ForEach(fontDesigns, id: \.self) { design in ForEach(Font.Design.allCases, id: \.self) { design in
Text(design).tag(design) Text(FontUtils.stringFromFontDesign(design)).tag(design)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)

View File

@ -9,14 +9,34 @@ import SwiftUI
/// Component for displaying a single digit with fixed width and glow effects /// Component for displaying a single digit with fixed width and glow effects
struct DigitView: View { struct DigitView: View {
let digit: String @Environment(\.sizeCategory) private var sizeCategory
let fontSize: CGFloat
let opacity: Double @State var digit: String
let digitColor: Color @State var fontName: FontFamily
let glowIntensity: Double @State var weight: Font.Weight
let fontFamily: String @State var design: Font.Design
let fontWeight: String @State var opacity: Double
let fontDesign: String @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<CGFloat>) {
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 { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@ -27,17 +47,6 @@ struct DigitView: View {
.position(x: geometry.size.width / 2, y: geometry.size.height / 2) .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 { private var glowRadius: CGFloat {
@ -49,35 +58,105 @@ struct DigitView: View {
} }
private var glowText: some View { private var glowText: some View {
Text(digit) text
.font(customFont)
.foregroundColor(digitColor) .foregroundColor(digitColor)
.blur(radius: glowRadius) .blur(radius: glowRadius)
.opacity(glowOpacity) .opacity(glowOpacity)
} }
private var mainText: some View { private var mainText: some View {
Text(digit) text
.font(customFont)
.foregroundColor(digitColor) .foregroundColor(digitColor)
.opacity(opacity) .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 // MARK: - Preview
#Preview { #Preview {
let digitView = DigitView( @Previewable @State var sharedFontSize: CGFloat = 2000
digit: "8", @Previewable @State var fontName: FontFamily = .arial
fontSize: 80, @Previewable @State var weight: Font.Weight = .heavy
opacity: 1.0, @Previewable @State var design: Font.Design = .rounded
digitColor: .white, @Previewable @State var glowIntensity: Double = 0.6
glowIntensity: 0.2,
fontFamily: "System",
fontWeight: "Regular",
fontDesign: "Default"
)
return digitView HStack {
.background(Color.black) DigitView(digit: "8",
.frame(width: 100, height: 120) 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)
}
} }

View File

@ -13,7 +13,7 @@ struct DotCircle: View {
let opacity: Double let opacity: Double
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let fontWeight: String let fontWeight: Font.Weight
var body: some View { var body: some View {
// Calculate size based on font weight - make dots smaller for lighter weights // Calculate size based on font weight - make dots smaller for lighter weights
@ -41,7 +41,7 @@ struct DotCircle: View {
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Light" fontWeight: .light
) )
.background(Color.black) .background(Color.black)
} }
@ -52,7 +52,7 @@ struct DotCircle: View {
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Bold" fontWeight: .bold
) )
.background(Color.black) .background(Color.black)
} }
@ -64,14 +64,14 @@ struct DotCircle: View {
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Regular" fontWeight: .regular
) )
DotCircle( DotCircle(
size: 10, size: 10,
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Regular" fontWeight: .regular
) )
} }
.background(Color.black) .background(Color.black)

View File

@ -14,7 +14,7 @@ struct HorizontalColon: View {
let opacity: Double let opacity: Double
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let fontWeight: String let fontWeight: Font.Weight
var body: some View { var body: some View {
let clamped = ColorUtils.clampOpacity(opacity) let clamped = ColorUtils.clampOpacity(opacity)
@ -35,7 +35,7 @@ struct HorizontalColon: View {
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Regular" fontWeight: .regular
) )
.background(Color.black) .background(Color.black)
} }

View File

@ -19,10 +19,11 @@ struct TimeDisplayView: View {
let manualScale: Double let manualScale: Double
let stretched: Bool let stretched: Bool
let clockOpacity: Double let clockOpacity: Double
let fontFamily: String let fontFamily: FontFamily
let fontWeight: String let fontWeight: Font.Weight
let fontDesign: String let fontDesign: Font.Design
let forceHorizontalMode: Bool let forceHorizontalMode: Bool
@State var fontSize: CGFloat = 1000
// MARK: - Formatters // MARK: - Formatters
private static let hour24DF: DateFormatter = { private static let hour24DF: DateFormatter = {
@ -60,22 +61,6 @@ struct TimeDisplayView: View {
let portraitMode = containerSize.height >= containerSize.width let portraitMode = containerSize.height >= containerSize.width
let portrait = !forceHorizontalMode && 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 // 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)
let minute = Self.minuteDF.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 // Separators - reasonable spacing with extra padding in landscape
let dotDiameter = baseFontSize * 0.20 let dotDiameter = fontSize * 0.20
let hSpacing = portrait ? baseFontSize * 0.18 : baseFontSize * 0.25 // More spacing in landscape let hSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
let vSpacing = portrait ? baseFontSize * 0.22 : baseFontSize * 0.30 // 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 // Simple scaling - let the content size naturally and apply manual scale
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0))) 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 // Time display with consistent centering and stable layout
Group { Group {
if portrait { if portrait {
VStack(alignment: .center, spacing: 0) { VStack(alignment: .center) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) 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) 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 { if showSeconds {
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) 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 { } else {
HStack(alignment: .center, spacing: baseFontSize * 0.035) { HStack(alignment: .center) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign) 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) 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 { if showSeconds {
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight) 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) .frame(maxWidth: .infinity)

View File

@ -11,28 +11,27 @@ import Foundation
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits /// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
struct TimeSegment: View { struct TimeSegment: View {
let text: String let text: String
let fontSize: CGFloat @Binding var fontSize: CGFloat
let opacity: Double let opacity: Double
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let fontFamily: String let fontFamily: FontFamily
let fontWeight: String let fontWeight: Font.Weight
let fontDesign: String let fontDesign: Font.Design
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in ForEach(Array(text.enumerated()), id: \.offset) { index, character in
DigitView( DigitView(
digit: String(character), digit: String(character),
fontSize: fontSize, fontName: fontFamily,
opacity: clampedOpacity, weight: fontWeight,
design: fontDesign,
digitColor: digitColor, digitColor: digitColor,
opacity: clampedOpacity,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontFamily: fontFamily, fontSize: $fontSize
fontWeight: fontWeight,
fontDesign: fontDesign
) )
.frame(width: digitWidth)
.border(.red, width: 1) .border(.red, width: 1)
} }
} }
@ -44,43 +43,20 @@ struct TimeSegment: View {
private var clampedOpacity: Double { private var clampedOpacity: Double {
ColorUtils.clampOpacity(opacity) 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 // MARK: - Preview
#Preview { #Preview {
let segment = TimeSegment( @Previewable @State var fontSize: CGFloat = 100
TimeSegment(
text: "12", text: "12",
fontSize: 80, fontSize: $fontSize,
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.2, glowIntensity: 0.2,
fontFamily: "System", fontFamily: .system,
fontWeight: "Regular", fontWeight: .regular,
fontDesign: "Default" fontDesign: .default
) )
.background(Color.black)
return segment
.background(Color.black)
.frame(width: 200, height: 100)
} }

View File

@ -14,7 +14,7 @@ struct VerticalColon: View {
let opacity: Double let opacity: Double
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let fontWeight: String let fontWeight: Font.Weight
var body: some View { var body: some View {
let clamped = ColorUtils.clampOpacity(opacity) let clamped = ColorUtils.clampOpacity(opacity)
@ -35,7 +35,7 @@ struct VerticalColon: View {
opacity: 1.0, opacity: 1.0,
digitColor: .white, digitColor: .white,
glowIntensity: 0.3, glowIntensity: 0.3,
fontWeight: "Regular" fontWeight: .regular
) )
.background(Color.black) .background(Color.black)
} }