451 lines
19 KiB
Swift
451 lines
19 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 clockOpacity: Double
|
|
let fontFamily: String
|
|
let fontWeight: String
|
|
let fontDesign: String
|
|
|
|
// 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
|
|
|
|
// Use the full GeometryReader size for maximum space usage
|
|
let fullScreenSize = size
|
|
|
|
// Get safe area information for font sizing to avoid Dynamic Island overlap
|
|
let safeAreaInsets = proxy.safeAreaInsets
|
|
let _ = size.width - safeAreaInsets.leading - safeAreaInsets.trailing // availableWidth
|
|
let _ = size.height - safeAreaInsets.top - safeAreaInsets.bottom // availableHeight
|
|
|
|
// Use optimal font sizing that maximizes space usage with full screen
|
|
let baseFontSize = stretched ?
|
|
FontUtils.maximumStretchedFontSize(
|
|
containerWidth: fullScreenSize.width,
|
|
containerHeight: fullScreenSize.height,
|
|
isPortrait: portrait,
|
|
showSeconds: showSeconds,
|
|
showAmPm: false
|
|
) :
|
|
FontUtils.optimalFontSize(
|
|
containerWidth: fullScreenSize.width,
|
|
containerHeight: fullScreenSize.height,
|
|
isPortrait: portrait,
|
|
showSeconds: showSeconds,
|
|
showAmPm: false
|
|
)
|
|
let ampmFontSize = FontUtils.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 = false // Always use colon/dots instead of AM/PM
|
|
|
|
// Calculate sizes using fixed-width approach to prevent jumping
|
|
let digitUIFont = FontUtils.customUIFont(
|
|
size: baseFontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
)
|
|
let ampmUIFont = FontUtils.customUIFont(
|
|
size: ampmFontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
)
|
|
|
|
// Calculate consistent sizes for layout
|
|
let _ = measureText("8", font: digitUIFont).height // Use 8 as reference height
|
|
|
|
// Calculate the width of "88" for consistent sizing
|
|
let testFont = FontUtils.customUIFont(
|
|
size: baseFontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
)
|
|
let _ = calculateMaxTextWidth(font: testFont)
|
|
|
|
// Calculate width and height for a single digit (using "8" as the reference)
|
|
let singleDigitWidth = calculateMaxTextWidth(font: testFont, text: "8")
|
|
let singleDigitHeight = calculateMaxTextHeight(font: testFont, text: "8")
|
|
|
|
// All time segments use the same fixed width and height to prevent shifting
|
|
let hourSize = CGSize(width: singleDigitWidth * 2, height: singleDigitHeight)
|
|
let minuteSize = CGSize(width: singleDigitWidth * 2, height: singleDigitHeight)
|
|
let secondsSize = showSeconds ? CGSize(width: singleDigitWidth * 2, height: singleDigitHeight) : .zero
|
|
let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero
|
|
|
|
// Separators - reasonable spacing with extra padding in landscape
|
|
let dotDiameter = baseFontSize * 0.20
|
|
let hSpacing = portrait ? baseFontSize * 0.18 : baseFontSize * 0.25 // More spacing in landscape
|
|
let vSpacing = portrait ? baseFontSize * 0.22 : baseFontSize * 0.30 // More spacing in landscape
|
|
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 with maximum space utilization using full screen
|
|
let safeInset = AppConstants.Defaults.safeInset
|
|
let availableW = max(1, fullScreenSize.width - safeInset * 2)
|
|
let availableH = max(1, fullScreenSize.height - safeInset * 2)
|
|
|
|
// Calculate scaling factors
|
|
let widthScale = availableW / max(totalWidth, 1)
|
|
let heightScale = availableH / max(totalHeight, 1)
|
|
|
|
// For stretched mode, use reasonable scaling
|
|
let effectiveScale = stretched ?
|
|
max(0.1, min(min(widthScale, heightScale), 1.5)) : // Use min scale and cap at 1.5x to prevent overflow
|
|
max(0.1, max(0.1, min(widthScale, heightScale)) * CGFloat(max(0.1, min(manualScale, 1.0))))
|
|
|
|
let finalScale = effectiveScale
|
|
|
|
// Time display with consistent centering and stable layout
|
|
Group {
|
|
if portrait {
|
|
VStack(spacing: 0) {
|
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
if showAMPM {
|
|
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
} else {
|
|
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)
|
|
if showSeconds {
|
|
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)
|
|
} else {
|
|
// Invisible placeholder to maintain consistent spacing
|
|
Spacer()
|
|
.frame(height: baseFontSize * 0.3)
|
|
}
|
|
}
|
|
} else {
|
|
HStack(spacing: baseFontSize * 0.035) {
|
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
if showAMPM {
|
|
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
} else {
|
|
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)
|
|
if showSeconds {
|
|
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)
|
|
} else {
|
|
// Invisible placeholder to maintain consistent spacing
|
|
Spacer()
|
|
.frame(width: baseFontSize * 0.3)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(width: fullScreenSize.width, height: fullScreenSize.height, alignment: .center)
|
|
.scaleEffect(finalScale, anchor: .center)
|
|
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
|
|
.animation(UIConstants.AnimationCurves.smooth, value: showSeconds) // Smooth animation for seconds toggle
|
|
.minimumScaleFactor(0.1)
|
|
.clipped() // Prevent overflow beyond bounds
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onOrientationChange() // Force updates on orientation changes
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
private func measureText(_ text: String, font: UIFont) -> CGSize {
|
|
return FontUtils.measureTextSize(text: text, font: font)
|
|
}
|
|
|
|
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
|
|
|
|
// Calculate width of text for the given font - this ensures consistent width
|
|
private func calculateMaxTextWidth(font: UIFont, text: String = "88") -> CGFloat {
|
|
let attributes = [NSAttributedString.Key.font: font]
|
|
let size = (text as NSString).size(withAttributes: attributes)
|
|
return size.width
|
|
}
|
|
|
|
// Calculate height of text for the given font - this ensures consistent height
|
|
private func calculateMaxTextHeight(font: UIFont, text: String = "8") -> CGFloat {
|
|
let attributes = [NSAttributedString.Key.font: font]
|
|
let size = (text as NSString).size(withAttributes: attributes)
|
|
return size.height
|
|
}
|
|
|
|
private struct TimeSegment: View {
|
|
let text: String
|
|
let fontSize: CGFloat
|
|
let opacity: Double
|
|
let digitColor: Color
|
|
let glowIntensity: Double
|
|
let fontFamily: String
|
|
let fontWeight: String
|
|
let fontDesign: String
|
|
|
|
var body: some View {
|
|
let clamped = ColorUtils.clampOpacity(opacity)
|
|
let font = FontUtils.customUIFont(
|
|
size: fontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
)
|
|
let singleDigitWidth = calculateMaxTextWidth(font: font, text: "8")
|
|
let singleDigitHeight = calculateMaxTextHeight(font: font, text: "8")
|
|
let totalWidth = singleDigitWidth * CGFloat(text.count)
|
|
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
|
DigitView(
|
|
digit: String(character),
|
|
fontSize: fontSize,
|
|
opacity: clamped,
|
|
digitColor: digitColor,
|
|
glowIntensity: glowIntensity,
|
|
fontFamily: fontFamily,
|
|
fontWeight: fontWeight,
|
|
fontDesign: fontDesign,
|
|
digitWidth: singleDigitWidth,
|
|
digitHeight: singleDigitHeight
|
|
)
|
|
}
|
|
}
|
|
.frame(width: totalWidth, alignment: .center)
|
|
}
|
|
|
|
// Calculate width of text for the given font - this ensures consistent width
|
|
private func calculateMaxTextWidth(font: UIFont, text: String = "88") -> CGFloat {
|
|
let attributes = [NSAttributedString.Key.font: font]
|
|
let size = (text as NSString).size(withAttributes: attributes)
|
|
return size.width
|
|
}
|
|
}
|
|
|
|
private struct DigitView: View {
|
|
let digit: String
|
|
let fontSize: CGFloat
|
|
let opacity: Double
|
|
let digitColor: Color
|
|
let glowIntensity: Double
|
|
let fontFamily: String
|
|
let fontWeight: String
|
|
let fontDesign: String
|
|
let digitWidth: CGFloat
|
|
let digitHeight: CGFloat
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Text(digit)
|
|
.font(FontUtils.customFont(
|
|
size: fontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
))
|
|
.foregroundColor(digitColor)
|
|
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
|
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
|
.multilineTextAlignment(.center)
|
|
Text(digit)
|
|
.font(FontUtils.customFont(
|
|
size: fontSize,
|
|
family: fontFamily,
|
|
weight: fontWeight,
|
|
design: fontDesign
|
|
))
|
|
.foregroundColor(digitColor)
|
|
.opacity(opacity)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(width: digitWidth, height: digitHeight, alignment: .center)
|
|
.fixedSize(horizontal: false, vertical: false)
|
|
.lineLimit(1)
|
|
.allowsTightening(false)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
|
|
private struct HorizontalColon: View {
|
|
let dotDiameter: CGFloat
|
|
let spacing: CGFloat
|
|
let opacity: Double
|
|
let digitColor: Color
|
|
let glowIntensity: Double
|
|
let fontWeight: String
|
|
|
|
var body: some View {
|
|
let clamped = ColorUtils.clampOpacity(opacity)
|
|
HStack(spacing: spacing) {
|
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
}
|
|
.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
|
|
let fontWeight: String
|
|
|
|
var body: some View {
|
|
let clamped = ColorUtils.clampOpacity(opacity)
|
|
VStack(spacing: spacing) {
|
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
}
|
|
.fixedSize(horizontal: true, vertical: true)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct DotCircle: View {
|
|
let size: CGFloat
|
|
let opacity: Double
|
|
let digitColor: Color
|
|
let glowIntensity: Double
|
|
let fontWeight: String
|
|
|
|
var body: some View {
|
|
let weightMultiplier = FontUtils.weightMultiplier(for: fontWeight)
|
|
let adjustedSize = size * weightMultiplier
|
|
|
|
ZStack {
|
|
Circle()
|
|
.fill(digitColor)
|
|
.frame(width: adjustedSize, height: adjustedSize)
|
|
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
|
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
|
Circle()
|
|
.fill(digitColor)
|
|
.frame(width: adjustedSize, height: adjustedSize)
|
|
.opacity(opacity)
|
|
}
|
|
}
|
|
}
|