TheNoiseClock/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
Matt Bruce 204aabf8d2 refactored
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-09-08 06:48:25 -05:00

294 lines
12 KiB
Swift

//
// TimeDisplayView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
/// Component for displaying segmented time with customizable formatting
struct TimeDisplayView: View {
// MARK: - Properties
let date: Date
let use24Hour: Bool
let showSeconds: Bool
let digitColor: Color
let glowIntensity: Double
let manualScale: Double
let stretched: Bool
let showAmPmBadge: Bool
let clockOpacity: Double
// 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 secondDF: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "ss"
return df
}()
private static let ampmDF: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "a"
return df
}()
// MARK: - Body
var body: some View {
GeometryReader { proxy in
let size = proxy.size
let portrait = size.height >= size.width
let baseFontSize = ColorUtils.dynamicFontSize(containerWidth: size.width, containerHeight: size.height)
let ampmFontSize = ColorUtils.ampmFontSize(baseFontSize: baseFontSize)
// Time components
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
let minute = Self.minuteDF.string(from: date)
let secondsText = Self.secondDF.string(from: date)
let ampmText = Self.ampmDF.string(from: date)
let showAMPM = !use24Hour && showAmPmBadge
// Calculate sizes
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
let hourSize = measureText(hour, font: digitUIFont)
let minuteSize = measureText(minute, font: digitUIFont)
let secondsSize = showSeconds ? measureText(secondsText, font: digitUIFont) : .zero
let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero
// Separators
let dotDiameter = baseFontSize * 0.20
let hSpacing = baseFontSize * 0.18
let vSpacing = baseFontSize * 0.22
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
// Calculate layout
let (totalWidth, totalHeight) = calculateLayoutSize(
portrait: portrait,
hourSize: hourSize,
minuteSize: minuteSize,
secondsSize: secondsSize,
ampmSize: ampmSize,
horizontalSepSize: horizontalSepSize,
verticalSepSize: verticalSepSize,
showSeconds: showSeconds,
showAMPM: showAMPM
)
// Calculate scale
let safeInset = AppConstants.Defaults.safeInset
let availableW = max(1, size.width - safeInset * 2)
let availableH = max(1, size.height - safeInset * 2)
let widthScale = availableW / max(totalWidth, 1)
let heightScale = availableH / max(totalHeight, 1)
let fittedScale = max(0.1, min(widthScale, heightScale))
let manualPercent = max(0.0, min(manualScale, 1.0))
let effectiveScale = stretched ? fittedScale : max(0.05, fittedScale * CGFloat(manualPercent))
// Time display
Group {
if portrait {
VStack(spacing: 0) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
if showAMPM {
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
} else {
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
}
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
if showSeconds {
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
}
}
} else {
HStack(spacing: 0) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
if showAMPM {
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
} else {
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
}
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
if showSeconds {
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
}
}
}
}
.frame(width: size.width, height: size.height, alignment: .center)
.scaleEffect(effectiveScale, anchor: .center)
.animation(UIConstants.AnimationCurves.smooth, value: effectiveScale)
.minimumScaleFactor(0.1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Helper Methods
private func measureText(_ text: String, font: UIFont) -> CGSize {
let attributes = [NSAttributedString.Key.font: font]
return (text as NSString).size(withAttributes: attributes)
}
private func calculateLayoutSize(
portrait: Bool,
hourSize: CGSize,
minuteSize: CGSize,
secondsSize: CGSize,
ampmSize: CGSize,
horizontalSepSize: CGSize,
verticalSepSize: CGSize,
showSeconds: Bool,
showAMPM: Bool
) -> (CGFloat, CGFloat) {
if portrait {
var widths: [CGFloat] = [hourSize.width, minuteSize.width]
var totalH: CGFloat = hourSize.height + minuteSize.height
if showAMPM {
widths.append(ampmSize.width)
totalH += ampmSize.height
} else {
widths.append(horizontalSepSize.width)
totalH += horizontalSepSize.height
}
if showSeconds {
widths.append(contentsOf: [horizontalSepSize.width, secondsSize.width])
totalH += horizontalSepSize.height + secondsSize.height
}
return (widths.max() ?? 0, totalH)
} else {
var totalW: CGFloat = hourSize.width + minuteSize.width
var heights: [CGFloat] = [hourSize.height, minuteSize.height]
if showAMPM {
totalW += ampmSize.width
heights.append(ampmSize.height)
} else {
totalW += verticalSepSize.width
heights.append(verticalSepSize.height)
}
if showSeconds {
totalW += verticalSepSize.width + secondsSize.width
heights.append(contentsOf: [verticalSepSize.height, secondsSize.height])
}
return (totalW, heights.max() ?? 0)
}
}
}
// MARK: - Supporting Views
private struct TimeSegment: View {
let text: String
let fontSize: CGFloat
let opacity: Double
let digitColor: Color
let glowIntensity: Double
var body: some View {
let clamped = ColorUtils.clampOpacity(opacity)
ZStack {
Text(text)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(digitColor)
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * clamped)
Text(text)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(digitColor)
.opacity(clamped)
}
.fixedSize(horizontal: true, vertical: true)
.lineLimit(1)
.allowsTightening(true)
.multilineTextAlignment(.center)
}
}
private struct HorizontalColon: View {
let dotDiameter: CGFloat
let spacing: CGFloat
let opacity: Double
let digitColor: Color
let glowIntensity: Double
var body: some View {
let clamped = ColorUtils.clampOpacity(opacity)
HStack(spacing: spacing) {
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
}
.fixedSize(horizontal: true, vertical: true)
.accessibilityHidden(true)
}
}
private struct VerticalColon: View {
let dotDiameter: CGFloat
let spacing: CGFloat
let opacity: Double
let digitColor: Color
let glowIntensity: Double
var body: some View {
let clamped = ColorUtils.clampOpacity(opacity)
VStack(spacing: spacing) {
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity)
}
.fixedSize(horizontal: true, vertical: true)
.accessibilityHidden(true)
}
}
private struct DotCircle: View {
let size: CGFloat
let opacity: Double
let digitColor: Color
let glowIntensity: Double
var body: some View {
ZStack {
Circle()
.fill(digitColor)
.frame(width: size, height: size)
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
Circle()
.fill(digitColor)
.frame(width: size, height: size)
.opacity(opacity)
}
}
}