349 lines
17 KiB
Swift
349 lines
17 KiB
Swift
//
|
||
// TimeDisplayView.swift
|
||
// TheNoiseClock
|
||
//
|
||
// Created by Matt Bruce on 9/7/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
import Bedrock
|
||
|
||
/// Component for displaying segmented time with customizable formatting
|
||
struct TimeDisplayView: View {
|
||
|
||
// MARK: - Debug Configuration
|
||
/// When true, shows static "88:88:88" to debug font sizing
|
||
private static let debugStaticTime = false
|
||
/// When true, shows debug overlays with sizing info
|
||
private static let debugShowOverlays = false
|
||
/// Static hour value for debug mode
|
||
private static let debugHour = "88"
|
||
/// Static minute value for debug mode
|
||
private static let debugMinute = "88"
|
||
/// Static seconds value for debug mode
|
||
private static let debugSeconds = "88"
|
||
|
||
// MARK: - Properties
|
||
let date: Date
|
||
let use24Hour: Bool
|
||
let showSeconds: Bool
|
||
let showAmPm: Bool
|
||
let digitColor: Color
|
||
let glowIntensity: Double
|
||
let clockOpacity: Double
|
||
let fontFamily: FontFamily
|
||
let fontWeight: Font.Weight
|
||
let fontDesign: Font.Design
|
||
let forceHorizontalMode: Bool
|
||
let isDisplayMode: Bool
|
||
let animationStyle: DigitAnimationStyle
|
||
@State var fontSize: CGFloat = 100
|
||
@State private var lastCalculatedContainerSize: CGSize = .zero
|
||
|
||
// MARK: - Layout Constants
|
||
private enum Layout {
|
||
static let dotDiameterMultiplier: CGFloat = 0.65
|
||
static let dotSpacingPortraitMultiplier: CGFloat = 0.16
|
||
static let dotSpacingLandscapeMultiplier: CGFloat = 0.22
|
||
}
|
||
|
||
// MARK: - Formatters
|
||
private static let hour24DF: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "en_US_POSIX")
|
||
df.dateFormat = "HH"
|
||
return df
|
||
}()
|
||
|
||
private static let hour12DF: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "en_US_POSIX")
|
||
df.dateFormat = "h"
|
||
return df
|
||
}()
|
||
|
||
private static let minuteDF: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "en_US_POSIX")
|
||
df.dateFormat = "mm"
|
||
return df
|
||
}()
|
||
|
||
private static let amPmDF: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "en_US_POSIX")
|
||
df.dateFormat = "a"
|
||
return df
|
||
}()
|
||
|
||
private static let secondDF: DateFormatter = {
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "en_US_POSIX")
|
||
df.dateFormat = "ss"
|
||
return df
|
||
}()
|
||
|
||
// MARK: - Body
|
||
var body: some View {
|
||
return GeometryReader { proxy in
|
||
let containerSize = proxy.size
|
||
let portraitMode = containerSize.height >= containerSize.width
|
||
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
|
||
|
||
// Time components - use debug static values if enabled
|
||
let hour: String
|
||
let minute: String
|
||
let secondsText: String
|
||
|
||
if Self.debugStaticTime {
|
||
hour = Self.debugHour
|
||
minute = Self.debugMinute
|
||
secondsText = Self.debugSeconds
|
||
} else {
|
||
hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||
minute = Self.minuteDF.string(from: date)
|
||
secondsText = Self.secondDF.string(from: date)
|
||
}
|
||
|
||
|
||
// AM/PM badge - hide in debug mode
|
||
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
|
||
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
|
||
|
||
// Separators - reasonable spacing with extra padding in landscape
|
||
let dotDiameter = fontSize * Layout.dotDiameterMultiplier
|
||
let dotSpacing = portrait
|
||
? fontSize * Layout.dotSpacingPortraitMultiplier
|
||
: fontSize * Layout.dotSpacingLandscapeMultiplier
|
||
|
||
// Time display with consistent centering and stable layout
|
||
return Group {
|
||
if portrait {
|
||
VStack(alignment: .center, spacing: 0) {
|
||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
if showSeconds {
|
||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
||
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity) // Center horizontally in portrait
|
||
} else {
|
||
// Landscape mode - use fixed digit widths for stable layout
|
||
// Each digit gets the same width (based on "8") so clock doesn't shift
|
||
let fixedDigitWidth = FontUtils.digitWidth(
|
||
fontName: fontFamily,
|
||
weight: fontWeight,
|
||
design: fontDesign,
|
||
fontSize: fontSize
|
||
)
|
||
let segmentWidth = fixedDigitWidth * 2 // Minutes/seconds always 2 digits
|
||
// Hour width is dynamic based on actual digit count (1 or 2 digits)
|
||
let hourSegmentWidth = fixedDigitWidth * CGFloat(hour.count)
|
||
|
||
HStack(alignment: .center, spacing: 0) {
|
||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
.frame(width: hourSegmentWidth)
|
||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
||
.frame(width: dotDiameter)
|
||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
.frame(width: segmentWidth)
|
||
if showSeconds {
|
||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
||
.frame(width: dotDiameter)
|
||
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
||
.frame(width: segmentWidth)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Center in landscape
|
||
}
|
||
}
|
||
.overlay(alignment: .bottomTrailing) {
|
||
if shouldShowAmPm {
|
||
Text(amPmText)
|
||
.font(
|
||
FontUtils.createFont(
|
||
name: fontFamily,
|
||
weight: fontWeight,
|
||
design: fontDesign,
|
||
size: max(12, fontSize * 0.18)
|
||
)
|
||
)
|
||
.foregroundColor(digitColor)
|
||
.opacity(clockOpacity)
|
||
.padding(.horizontal, max(6, fontSize * 0.05))
|
||
.padding(.vertical, max(3, fontSize * 0.03))
|
||
}
|
||
}
|
||
.offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode
|
||
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
|
||
.minimumScaleFactor(0.1)
|
||
.clipped() // Prevent overflow beyond bounds
|
||
.overlay(alignment: .topLeading) {
|
||
if Self.debugShowOverlays {
|
||
debugInfoOverlay(containerSize: containerSize, portrait: portrait)
|
||
}
|
||
}
|
||
.debugBorder(Self.debugShowOverlays, color: .cyan, label: "TimeDisplayView")
|
||
.onAppear {
|
||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
.onChange(of: containerSize) { _, newSize in
|
||
updateFontSize(containerSize: newSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
.onChange(of: showSeconds) { _, _ in
|
||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
.onChange(of: fontFamily) { _, _ in
|
||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
.onChange(of: fontWeight) { _, _ in
|
||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
.onChange(of: fontDesign) { _, _ in
|
||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.onOrientationChange() // Force updates on orientation changes
|
||
}
|
||
|
||
// MARK: - Debug Overlay
|
||
@ViewBuilder
|
||
private func debugInfoOverlay(containerSize: CGSize, portrait: Bool) -> some View {
|
||
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
|
||
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
|
||
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
|
||
let colonSize = fontSize * Layout.dotDiameterMultiplier
|
||
|
||
let availableWidth = portrait
|
||
? containerSize.width
|
||
: max(1, containerSize.width - (colonCount * colonSize))
|
||
let availableHeight = portrait
|
||
? max(1, containerSize.height - (colonCount * colonSize))
|
||
: containerSize.height
|
||
let digitSlotSize = CGSize(
|
||
width: max(1, availableWidth / digitColumns),
|
||
height: max(1, availableHeight / digitRows)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Container: \(Int(containerSize.width))×\(Int(containerSize.height))")
|
||
Text("Digit Slot: \(Int(digitSlotSize.width))×\(Int(digitSlotSize.height))")
|
||
Text("Font Size: \(Int(fontSize))")
|
||
Text("Cols: \(Int(digitColumns)) Rows: \(Int(digitRows))")
|
||
Text("Portrait: \(portrait)")
|
||
Text("Family: \(fontFamily.rawValue)")
|
||
}
|
||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||
.foregroundStyle(.yellow)
|
||
.padding(4)
|
||
.background(Color.black.opacity(0.7))
|
||
.clipShape(.rect(cornerRadius: 4))
|
||
.padding(8)
|
||
}
|
||
|
||
// MARK: - Font Sizing
|
||
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
|
||
guard containerSize != .zero else { return }
|
||
|
||
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
|
||
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
|
||
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
|
||
let previousFontSize = fontSize
|
||
|
||
func calculateFontSize(reservingColonSize colonSize: CGFloat) -> CGFloat {
|
||
let availableWidth = portrait
|
||
? containerSize.width
|
||
: max(1, containerSize.width - (colonCount * colonSize))
|
||
let availableHeight = portrait
|
||
? max(1, containerSize.height - (colonCount * colonSize))
|
||
: containerSize.height
|
||
let digitSize = CGSize(
|
||
width: max(1, availableWidth / digitColumns),
|
||
height: max(1, availableHeight / digitRows)
|
||
)
|
||
|
||
//Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||
//Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||
//Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||
|
||
return FontUtils.calculateOptimalFontSize(
|
||
digit: "8",
|
||
fontName: fontFamily,
|
||
weight: fontWeight,
|
||
design: fontDesign,
|
||
for: digitSize,
|
||
isDisplayMode: isDisplayMode
|
||
)
|
||
}
|
||
|
||
var estimated = calculateFontSize(reservingColonSize: 0)
|
||
for _ in 0..<2 {
|
||
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||
estimated = calculateFontSize(reservingColonSize: dotDiameter)
|
||
}
|
||
|
||
if !portrait {
|
||
// Verify colon height fits
|
||
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||
let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
|
||
let colonHeight = (dotDiameter * 2) + dotSpacing
|
||
if colonHeight > containerSize.height {
|
||
estimated *= containerSize.height / colonHeight
|
||
}
|
||
|
||
// CRITICAL: Verify total width fits to prevent clipping
|
||
// Calculate actual widths that will be used in layout
|
||
let actualDigitWidth = FontUtils.digitWidth(
|
||
fontName: fontFamily,
|
||
weight: fontWeight,
|
||
design: fontDesign,
|
||
fontSize: estimated
|
||
)
|
||
let actualColonWidth = estimated * Layout.dotDiameterMultiplier
|
||
let segmentCount: CGFloat = showSeconds ? 3.0 : 2.0
|
||
let totalWidth = (actualDigitWidth * 2 * segmentCount) + (colonCount * actualColonWidth)
|
||
|
||
// If total width exceeds container, scale down
|
||
if totalWidth > containerSize.width {
|
||
let scaleFactor = containerSize.width / totalWidth
|
||
estimated *= scaleFactor * 0.98 // Add 2% margin
|
||
//Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||
}
|
||
}
|
||
|
||
//Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||
|
||
if abs(estimated - fontSize) > 1 {
|
||
fontSize = estimated
|
||
//Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||
} else {
|
||
// Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||
}
|
||
lastCalculatedContainerSize = containerSize
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Preview
|
||
#Preview {
|
||
TimeDisplayView(
|
||
date: Date(),
|
||
use24Hour: true,
|
||
showSeconds: false,
|
||
showAmPm: true,
|
||
digitColor: .white,
|
||
glowIntensity: 0.2,
|
||
clockOpacity: 1.0,
|
||
fontFamily: .verdana,
|
||
fontWeight: .medium,
|
||
fontDesign: .default,
|
||
forceHorizontalMode: false,
|
||
isDisplayMode: false,
|
||
animationStyle: .spring
|
||
)
|
||
.background(Color.black)
|
||
}
|