diff --git a/PRD.md b/PRD.md index bd0919e..5c1c9a6 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 8bc45f1..27be183 100644 --- a/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/TheNoiseClock.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ TheNoiseClock.xcscheme_^#shared#^_ orderHint - 1 + 0 diff --git a/TheNoiseClock/Core/Extensions/View+Extensions.swift b/TheNoiseClock/Core/Extensions/View+Extensions.swift index e7d751a..42603a7 100644 --- a/TheNoiseClock/Core/Extensions/View+Extensions.swift +++ b/TheNoiseClock/Core/Extensions/View+Extensions.swift @@ -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 diff --git a/TheNoiseClock/Views/Clock/ClockView.swift b/TheNoiseClock/Views/Clock/ClockView.swift index 521dd5a..35f3c0e 100644 --- a/TheNoiseClock/Views/Clock/ClockView.swift +++ b/TheNoiseClock/Views/Clock/ClockView.swift @@ -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) } diff --git a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift index 97d83ff..dcf26e1 100644 --- a/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Views/Clock/Components/ClockDisplayContainer.swift @@ -60,4 +60,6 @@ struct ClockDisplayContainer: View { style: ClockStyle(), isDisplayMode: false ) + .frame(width: 400, height: 600) + .background(Color.black) } diff --git a/TheNoiseClock/Views/Clock/Components/ClockTabBarManager.swift b/TheNoiseClock/Views/Clock/Components/ClockTabBarManager.swift index b016112..d42927c 100644 --- a/TheNoiseClock/Views/Clock/Components/ClockTabBarManager.swift +++ b/TheNoiseClock/Views/Clock/Components/ClockTabBarManager.swift @@ -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) } diff --git a/TheNoiseClock/Views/Clock/Components/DigitView.swift b/TheNoiseClock/Views/Clock/Components/DigitView.swift new file mode 100644 index 0000000..bf29a88 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/DigitView.swift @@ -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) +} diff --git a/TheNoiseClock/Views/Clock/Components/DotCircle.swift b/TheNoiseClock/Views/Clock/Components/DotCircle.swift new file mode 100644 index 0000000..1d60445 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/DotCircle.swift @@ -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) +} diff --git a/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift b/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift new file mode 100644 index 0000000..e9cda3b --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/HorizontalColon.swift @@ -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) +} diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift index f94f8ab..6afc4e1 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -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) } diff --git a/TheNoiseClock/Views/Clock/Components/TimeSegment.swift b/TheNoiseClock/Views/Clock/Components/TimeSegment.swift new file mode 100644 index 0000000..346e440 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/TimeSegment.swift @@ -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) +} diff --git a/TheNoiseClock/Views/Clock/Components/VerticalColon.swift b/TheNoiseClock/Views/Clock/Components/VerticalColon.swift new file mode 100644 index 0000000..95d9986 --- /dev/null +++ b/TheNoiseClock/Views/Clock/Components/VerticalColon.swift @@ -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) +}