294 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|