TheNoiseClock/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift

349 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}