Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-10 05:44:59 -05:00
parent 5a752d178a
commit ed6e8c2635
12 changed files with 444 additions and 346 deletions

60
PRD.md
View File

@ -41,6 +41,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Normal mode**: Standard interface with navigation and settings
- **Display mode**: Full-screen clock activated by long-press (0.6 seconds)
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode
- **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
- **Smooth transitions**: Animated transitions between modes
- **Status bar control**: Status bar automatically hidden in full-screen mode
- **Safe area expansion**: Clock expands into tab bar area when hidden
@ -328,6 +330,8 @@ These principles are fundamental to the project's long-term success and must be
- **Interactive controls**: Toggles, sliders, color pickers
- **Real-time updates**: Changes apply immediately
- **Sheet presentation**: Modal settings with detents
- **iPad optimization**: Settings sheet opens at full size (.large) on iPad for better usability
- **iPhone compatibility**: Settings sheet uses medium/large detents on iPhone for optimal space usage
## File Structure and Organization
@ -576,6 +580,62 @@ The following changes **automatically require** PRD updates:
- **Weather integration**: Weather-based alarm sounds
- **Health integration**: Sleep tracking integration
## Build and Development
### Terminal Build Commands
The following terminal commands are used for building and testing the project. These commands have been tested and work reliably:
#### Basic Build Commands
```bash
# Navigate to project directory
cd /Users/mattbruce/Documents/Projects/TheNoiseClock
# Build for iOS Simulator (iPad mini)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
# Build for iOS Simulator (any device)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build
# Build for physical device (requires provisioning profile)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock build
```
#### Error Checking Commands
```bash
# Check for build errors only (filtered output)
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
# Quick syntax check for specific files
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeSegment.swift
swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
```
#### Available Simulators
The following simulators are available for testing:
- **iPad mini (A17 Pro)** - Primary testing device
- **iPad (10th generation)**
- **iPad Air 11-inch (M2)**
- **iPad Air 13-inch (M2)**
- **iPad Pro 11-inch (M4)**
- **iPad Pro 13-inch (M4)**
- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max**
- **iPhone SE (3rd generation)**
#### Build Troubleshooting
1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds
2. **Missing Files**: Ensure all new Swift files are added to the Xcode project target
3. **Preview Compilation Errors**: Break down complex expressions into computed properties
4. **Package Dependencies**: AudioPlaybackKit is included as local package dependency
#### Development Workflow
1. **Make code changes** in Xcode or via AI assistant
2. **Test build** using terminal commands above
3. **Fix any errors** identified in build output
4. **Test on simulator** using Xcode or terminal build
5. **Update PRD** if architectural changes are made
## Development Notes
### Project Information

View File

@ -7,7 +7,7 @@
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
</dict>
</dict>

View File

@ -34,28 +34,6 @@ extension View {
.disabled(!isEnabled)
}
/// Hide tab bar with animation
/// - Parameters:
/// - hidden: Whether to hide the tab bar
/// - animated: Whether to animate the change
func hideTabBar(_ hidden: Bool, animated: Bool = true) {
#if canImport(UIKit)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let tabBarController = window.rootViewController?.findTabBarController() else { return }
let tabBar = tabBarController.tabBar
let changes = {
tabBar.alpha = hidden ? 0 : 1
}
if animated {
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
} else {
changes()
}
tabBar.isUserInteractionEnabled = !hidden
#endif
}
/// Apply responsive font sizing that updates on orientation and layout changes
/// - Parameters:
@ -155,22 +133,3 @@ struct OrientationChangeModifier: ViewModifier {
.id(orientation.rawValue) // Force view recreation on orientation change
}
}
#if canImport(UIKit)
// Made internal (module-wide) so it can be used from other files like ClockView.swift
extension UIViewController {
func findTabBarController() -> UITabBarController? {
if let tbc = self as? UITabBarController { return tbc }
for child in children {
if let tbc = child.findTabBarController() { return tbc }
}
if let presented = presentedViewController {
return presented.findTabBarController()
}
if let nav = self as? UINavigationController {
return nav.visibleViewController?.findTabBarController()
}
return parent?.findTabBarController()
}
}
#endif

View File

@ -40,7 +40,9 @@ struct ClockView: View {
ClockSettingsView(style: viewModel.style) { newStyle in
viewModel.updateStyle(newStyle)
}
.presentationDetents([.medium, .large])
.presentationDetents(UIDevice.current.userInterfaceIdiom == .pad ? [.large] : [.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled)
}
.overlay {
// Toolbar overlay
@ -51,7 +53,7 @@ struct ClockView: View {
}
.overlay {
// Tab bar management overlay
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode, animated: true)
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
}
.overlay {
// Gesture handling overlay
@ -67,4 +69,6 @@ struct ClockView: View {
NavigationStack {
ClockView()
}
.frame(width: 400, height: 600)
.background(Color.black)
}

View File

@ -60,4 +60,6 @@ struct ClockDisplayContainer: View {
style: ClockStyle(),
isDisplayMode: false
)
.frame(width: 400, height: 600)
.background(Color.black)
}

View File

@ -8,48 +8,20 @@
import SwiftUI
/// Component that manages tab bar visibility for display mode
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
struct ClockTabBarManager: View {
// MARK: - Properties
let isDisplayMode: Bool
let animated: Bool
// MARK: - Body
var body: some View {
EmptyView()
.onAppear {
setTabBarHidden(isDisplayMode, animated: false)
}
.onDisappear {
setTabBarHidden(false, animated: false)
}
.onChange(of: isDisplayMode) { _, newValue in
setTabBarHidden(newValue, animated: animated)
}
}
// MARK: - Private Methods
private func setTabBarHidden(_ hidden: Bool, animated: Bool) {
#if canImport(UIKit)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let tabBarController = window.rootViewController?.findTabBarController() else { return }
let tabBar = tabBarController.tabBar
let changes = {
tabBar.alpha = hidden ? 0 : 1
}
if animated {
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
} else {
changes()
}
tabBar.isUserInteractionEnabled = !hidden
#endif
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
}
}
// MARK: - Preview
#Preview {
ClockTabBarManager(isDisplayMode: false, animated: false)
ClockTabBarManager(isDisplayMode: false)
}

View File

@ -0,0 +1,88 @@
//
// DigitView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/9/25.
//
import SwiftUI
/// Component for displaying a single digit with fixed width and glow effects
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 {
GeometryReader { geometry in
ZStack {
glowText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
mainText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
}
.frame(width: digitWidth, height: digitHeight)
.border(Color.blue, width: 2) // DEBUG: Blue border around individual digits
}
// MARK: - Computed Properties
private var customFont: Font {
FontUtils.customFont(
size: fontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
)
}
private var glowRadius: CGFloat {
ColorUtils.glowRadius(intensity: glowIntensity)
}
private var glowOpacity: Double {
ColorUtils.glowOpacity(intensity: glowIntensity) * opacity
}
private var glowText: some View {
Text(digit)
.font(customFont)
.foregroundColor(digitColor)
.blur(radius: glowRadius)
.opacity(glowOpacity)
}
private var mainText: some View {
Text(digit)
.font(customFont)
.foregroundColor(digitColor)
.opacity(opacity)
}
}
// MARK: - Preview
#Preview {
let digitView = DigitView(
digit: "8",
fontSize: 80,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.2,
fontFamily: "System",
fontWeight: "Regular",
fontDesign: "Default",
digitWidth: 60,
digitHeight: 100
)
return digitView
.background(Color.black)
.frame(width: 100, height: 120)
}

View File

@ -0,0 +1,78 @@
//
// DotCircle.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/9/25.
//
import SwiftUI
/// Component for displaying a single dot in the colon separator
struct DotCircle: View {
let size: CGFloat
let opacity: Double
let digitColor: Color
let glowIntensity: Double
let fontWeight: String
var body: some View {
// Calculate size based on font weight - make dots smaller for lighter weights
let sizeMultiplier = FontUtils.dotSizeMultiplier(for: fontWeight)
let adjustedSize = size * sizeMultiplier
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)
}
}
}
// MARK: - Preview
#Preview("Small Dot") {
DotCircle(
size: 8,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Light"
)
.background(Color.black)
}
#Preview("Large Dot") {
DotCircle(
size: 12,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Bold"
)
.background(Color.black)
}
#Preview("Multiple Dots") {
HStack(spacing: 10) {
DotCircle(
size: 10,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Regular"
)
DotCircle(
size: 10,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Regular"
)
}
.background(Color.black)
}

View File

@ -0,0 +1,41 @@
//
// HorizontalColon.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/9/25.
//
import SwiftUI
/// Component for displaying horizontal colon separator (two dots side by side)
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)
}
}
// MARK: - Preview
#Preview {
HorizontalColon(
dotDiameter: 12,
spacing: 8,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Regular"
)
.background(Color.black)
}

View File

@ -52,12 +52,6 @@ struct TimeDisplayView: View {
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 {
@ -89,14 +83,11 @@ struct TimeDisplayView: View {
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(
@ -105,35 +96,10 @@ struct TimeDisplayView: View {
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
@ -141,17 +107,12 @@ struct TimeDisplayView: View {
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
// Calculate layout
// Calculate layout - simplified without AM/PM
let (totalWidth, totalHeight) = calculateLayoutSize(
portrait: portrait,
hourSize: hourSize,
minuteSize: minuteSize,
secondsSize: secondsSize,
ampmSize: ampmSize,
horizontalSepSize: horizontalSepSize,
verticalSepSize: verticalSepSize,
showSeconds: showSeconds,
showAMPM: showAMPM
showSeconds: showSeconds
)
// Calculate scale with maximum space utilization using full screen
@ -173,43 +134,28 @@ struct TimeDisplayView: View {
// Time display with consistent centering and stable layout
Group {
if portrait {
VStack(spacing: 0) {
VStack(alignment: .center, 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)
}
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) {
HStack(alignment: .center, 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)
}
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)
}
}
}
}
.border(Color.red, width: 3) // DEBUG: Red border to check positioning
.frame(width: fullScreenSize.width, height: fullScreenSize.height, alignment: .center)
.scaleEffect(finalScale, anchor: .center)
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
@ -228,224 +174,37 @@ struct TimeDisplayView: View {
private func calculateLayoutSize(
portrait: Bool,
hourSize: CGSize,
minuteSize: CGSize,
secondsSize: CGSize,
ampmSize: CGSize,
horizontalSepSize: CGSize,
verticalSepSize: CGSize,
showSeconds: Bool,
showAMPM: Bool
showSeconds: Bool
) -> (CGFloat, CGFloat) {
// Simplified layout calculation without AM/PM
// This is just a placeholder since we're using natural sizing now
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)
let totalH = horizontalSepSize.height * (showSeconds ? 2 : 1)
return (0, totalH) // Width will be determined by content
} 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)
let totalW = verticalSepSize.width * (showSeconds ? 2 : 1)
return (totalW, 0) // Height will be determined by content
}
}
}
// 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 {
// Calculate size based on font weight - make dots smaller for lighter weights
let sizeMultiplier = FontUtils.dotSizeMultiplier(for: fontWeight)
let adjustedSize = size * sizeMultiplier
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)
}
}
// MARK: - Preview
#Preview {
let style = ClockStyle()
return TimeDisplayView(
date: Date(),
use24Hour: style.use24Hour,
showSeconds: style.showSeconds,
digitColor: style.digitColor,
glowIntensity: style.glowIntensity,
manualScale: style.digitScale,
stretched: style.stretched,
clockOpacity: style.clockOpacity,
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
fontDesign: style.fontDesign
) .background(Color.black)
}

View File

@ -0,0 +1,94 @@
//
// TimeSegment.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/9/25.
//
import SwiftUI
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
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 {
HStack(alignment: .center, spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
DigitView(
digit: String(character),
fontSize: fontSize,
opacity: clampedOpacity,
digitColor: digitColor,
glowIntensity: glowIntensity,
fontFamily: fontFamily,
fontWeight: fontWeight,
fontDesign: fontDesign,
digitWidth: singleDigitWidth,
digitHeight: singleDigitHeight
)
}
}
.border(Color.green, width: 2) // DEBUG: Green border around time segments
}
// MARK: - Computed Properties
private var clampedOpacity: Double {
ColorUtils.clampOpacity(opacity)
}
private var customFont: UIFont {
FontUtils.customUIFont(
size: fontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
)
}
private var singleDigitWidth: CGFloat {
calculateMaxTextWidth(font: customFont, text: "8")
}
private var singleDigitHeight: CGFloat {
calculateMaxTextHeight(font: customFont, text: "8")
}
// Calculate width of text for the given font - this ensures consistent width
private func calculateMaxTextWidth(font: UIFont, text: String = "8") -> 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
}
}
// MARK: - Preview
#Preview {
let segment = TimeSegment(
text: "12",
fontSize: 80,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.2,
fontFamily: "System",
fontWeight: "Regular",
fontDesign: "Default"
)
return segment
.background(Color.black)
.frame(width: 200, height: 100)
}

View File

@ -0,0 +1,41 @@
//
// VerticalColon.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/9/25.
//
import SwiftUI
/// Component for displaying vertical colon separator (two dots stacked vertically)
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)
}
}
// MARK: - Preview
#Preview {
VerticalColon(
dotDiameter: 12,
spacing: 8,
opacity: 1.0,
digitColor: .white,
glowIntensity: 0.3,
fontWeight: "Regular"
)
.background(Color.black)
}