Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5b2e321fb8
commit
eafafcb3ab
@ -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)
|
|
||||||
|
extension Font.Design {
|
||||||
|
static var allCases: [Font.Design] {
|
||||||
|
[.default, .rounded, .monospaced, .serif]
|
||||||
|
}
|
||||||
|
var uiFontWidth: UIFont.Width {
|
||||||
|
switch self {
|
||||||
|
case .default: return .standard
|
||||||
|
case .rounded: return .standard
|
||||||
|
case .monospaced: return .condensed
|
||||||
|
case .serif: return .standard
|
||||||
|
@unknown default:
|
||||||
|
return .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font sizing and typography utilities
|
||||||
|
struct FontUtils {
|
||||||
|
|
||||||
|
static func weightedFontName(name: String, weight: Font.Weight, design: Font.Design) -> String {
|
||||||
|
let weightSuffix = weight.uiFontWeightSuffix
|
||||||
|
|
||||||
// Start with a reasonable base size
|
switch design {
|
||||||
let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15
|
case .rounded:
|
||||||
|
if name.lowercased() == "system" { return "System" }
|
||||||
|
return name + (weightSuffix.isEmpty ? "-Rounded" : weightSuffix + "Rounded")
|
||||||
|
case .monospaced:
|
||||||
|
if name.lowercased() == "system" { return "Courier" }
|
||||||
|
return name == "Courier" ? name + weightSuffix : name + (weightSuffix.isEmpty ? "-Mono" : weightSuffix + "Mono")
|
||||||
|
case .serif:
|
||||||
|
if name.lowercased() == "system" { return "TimesNewRomanPS" }
|
||||||
|
return name + weightSuffix
|
||||||
|
default:
|
||||||
|
return name + weightSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func calculateOptimalFontSize(digit: String, fontName: FontFamily, weight: Font.Weight, design: Font.Design, for size: CGSize) -> CGFloat {
|
||||||
|
var low: CGFloat = 1.0
|
||||||
|
var high: CGFloat = 2000.0
|
||||||
|
|
||||||
// Binary search for optimal size
|
while high - low > 0.01 {
|
||||||
var low: CGFloat = 10
|
let mid = (low + high) / 2
|
||||||
var high: CGFloat = AppConstants.Defaults.maxFontSize
|
let testFont = createUIFont(name: fontName, weight: weight, design: design, size: mid)
|
||||||
var bestSize: CGFloat = baseSize
|
let textSize = tightBoundingBox(for: digit, withFont: testFont)
|
||||||
|
|
||||||
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
|
if textSize.width <= size.width && textSize.height <= size.height {
|
||||||
let fitsHeight = textSize.height <= availableHeight
|
low = mid
|
||||||
|
|
||||||
if fitsWidth && fitsHeight {
|
|
||||||
bestSize = testSize
|
|
||||||
low = testSize
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user