diff --git a/TheNoiseClock/Core/Utilities/FontUtils.swift b/TheNoiseClock/Core/Utilities/FontUtils.swift index 92b9ad5..dc89521 100644 --- a/TheNoiseClock/Core/Utilities/FontUtils.swift +++ b/TheNoiseClock/Core/Utilities/FontUtils.swift @@ -121,6 +121,147 @@ enum FontUtils { return baseFontSize * 0.20 } + // MARK: - Font Customization + + /// Convert font family string to Font + /// - Parameter family: Font family name + /// - Returns: SwiftUI Font + static func fontFamily(_ family: String) -> Font { + switch family { + case "San Francisco": + return .system(.body, design: .default) + case "System": + return .system(.body, design: .default) + case "Monaco": + return .system(.body, design: .monospaced) + case "Courier": + return .system(.body, design: .monospaced) + default: + return .system(.body, design: .default) + } + } + + /// Convert font weight string to Font.Weight + /// - Parameter weight: Font weight name + /// - Returns: Font.Weight + static func fontWeight(_ weight: String) -> Font.Weight { + switch weight { + case "Ultra Light": + return .ultraLight + case "Thin": + return .thin + case "Light": + return .light + case "Regular": + return .regular + case "Medium": + return .medium + case "Semibold": + return .semibold + case "Bold": + return .bold + case "Heavy": + return .heavy + case "Black": + return .black + default: + return .bold + } + } + + /// Convert font design string to Font.Design + /// - Parameter design: Font design name + /// - Returns: Font.Design + static func fontDesign(_ design: String) -> Font.Design { + switch design { + case "Default": + return .default + case "Serif": + return .serif + case "Rounded": + return .rounded + case "Monospaced": + return .monospaced + default: + return .rounded + } + } + + /// Create a custom font with specified parameters + /// - Parameters: + /// - size: Font size + /// - family: Font family name + /// - weight: Font weight name + /// - design: Font design name + /// - Returns: SwiftUI Font + static func customFont( + size: CGFloat, + family: String, + weight: String, + design: String + ) -> Font { + return .system( + size: size, + weight: fontWeight(weight), + design: fontDesign(design) + ) + } + + /// Create a UIFont with specified parameters for measurements + /// - Parameters: + /// - size: Font size + /// - family: Font family name + /// - weight: Font weight name + /// - design: Font design name + /// - Returns: UIFont + static func customUIFont( + size: CGFloat, + family: String, + weight: String, + design: String + ) -> UIFont { + let uiWeight: UIFont.Weight + switch weight { + case "Ultra Light": + uiWeight = .ultraLight + case "Thin": + uiWeight = .thin + case "Light": + uiWeight = .light + case "Regular": + uiWeight = .regular + case "Medium": + uiWeight = .medium + case "Semibold": + uiWeight = .semibold + case "Bold": + uiWeight = .bold + case "Heavy": + uiWeight = .heavy + case "Black": + uiWeight = .black + default: + uiWeight = .bold + } + + let uiDesign: UIFontDescriptor.SystemDesign + switch design { + case "Serif": + uiDesign = .serif + case "Rounded": + uiDesign = .rounded + case "Monospaced": + uiDesign = .monospaced + default: + uiDesign = .default + } + + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + .withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + + return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), size: size) + } + /// Measure text size with given font /// - Parameters: /// - text: Text to measure diff --git a/TheNoiseClock/Models/ClockStyle.swift b/TheNoiseClock/Models/ClockStyle.swift index 16513fc..acb14d7 100644 --- a/TheNoiseClock/Models/ClockStyle.swift +++ b/TheNoiseClock/Models/ClockStyle.swift @@ -25,6 +25,11 @@ class ClockStyle: Codable, Equatable { var stretched: Bool = true var backgroundHex: String = AppConstants.Defaults.backgroundColorHex + // MARK: - Font Settings + var fontFamily: String = "System" // System, San Francisco, etc. + var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black + var fontDesign: String = "Rounded" // Default, Serif, Rounded, Monospaced + // MARK: - Overlay Settings var showBattery: Bool = true var showDate: Bool = true @@ -46,6 +51,9 @@ class ClockStyle: Codable, Equatable { case digitScale case stretched case backgroundHex + case fontFamily + case fontWeight + case fontDesign case showBattery case showDate case clockOpacity @@ -70,6 +78,9 @@ class ClockStyle: Codable, Equatable { self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex + self.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily + self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight + self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity @@ -89,6 +100,9 @@ class ClockStyle: Codable, Equatable { try container.encode(digitScale, forKey: .digitScale) try container.encode(stretched, forKey: .stretched) try container.encode(backgroundHex, forKey: .backgroundHex) + try container.encode(fontFamily, forKey: .fontFamily) + try container.encode(fontWeight, forKey: .fontWeight) + try container.encode(fontDesign, forKey: .fontDesign) try container.encode(showBattery, forKey: .showBattery) try container.encode(showDate, forKey: .showDate) try container.encode(clockOpacity, forKey: .clockOpacity) @@ -131,6 +145,9 @@ class ClockStyle: Codable, Equatable { lhs.digitScale == rhs.digitScale && lhs.stretched == rhs.stretched && lhs.backgroundHex == rhs.backgroundHex && + lhs.fontFamily == rhs.fontFamily && + lhs.fontWeight == rhs.fontWeight && + lhs.fontDesign == rhs.fontDesign && lhs.showBattery == rhs.showBattery && lhs.showDate == rhs.showDate && lhs.clockOpacity == rhs.clockOpacity && diff --git a/TheNoiseClock/Views/Clock/ClockSettingsView.swift b/TheNoiseClock/Views/Clock/ClockSettingsView.swift index 71d4cc1..75dca9b 100644 --- a/TheNoiseClock/Views/Clock/ClockSettingsView.swift +++ b/TheNoiseClock/Views/Clock/ClockSettingsView.swift @@ -28,6 +28,7 @@ struct ClockSettingsView: View { NavigationView { Form { TimeFormatSection(style: $style) + FontSection(style: $style) AppearanceSection( style: $style, digitColor: $digitColor, @@ -56,6 +57,57 @@ struct ClockSettingsView: View { } // MARK: - Supporting Views +private struct FontSection: View { + @Binding var style: ClockStyle + + private let fontFamilies = ["System", "San Francisco", "Monaco", "Courier"] + private let fontWeights = ["Ultra Light", "Thin", "Light", "Regular", "Medium", "Semibold", "Bold", "Heavy", "Black"] + private let fontDesigns = ["Default", "Serif", "Rounded", "Monospaced"] + + var body: some View { + Section(header: Text("Font")) { + // Font Family + Picker("Family", selection: $style.fontFamily) { + ForEach(fontFamilies, id: \.self) { family in + Text(family).tag(family) + } + } + .pickerStyle(.menu) + + // Font Weight + Picker("Weight", selection: $style.fontWeight) { + ForEach(fontWeights, id: \.self) { weight in + Text(weight).tag(weight) + } + } + .pickerStyle(.menu) + + // Font Design + Picker("Design", selection: $style.fontDesign) { + ForEach(fontDesigns, id: \.self) { design in + Text(design).tag(design) + } + } + .pickerStyle(.menu) + + // Font Preview + HStack { + Text("Preview:") + .foregroundColor(.secondary) + Spacer() + Text("12:34") + .font(FontUtils.customFont( + size: 24, + family: style.fontFamily, + weight: style.fontWeight, + design: style.fontDesign + )) + .foregroundColor(.primary) + } + } + } +} + private struct TimeFormatSection: View { @Binding var style: ClockStyle diff --git a/TheNoiseClock/Views/Clock/ClockView.swift b/TheNoiseClock/Views/Clock/ClockView.swift index 15fad8e..ac6caf5 100644 --- a/TheNoiseClock/Views/Clock/ClockView.swift +++ b/TheNoiseClock/Views/Clock/ClockView.swift @@ -20,41 +20,45 @@ struct ClockView: View { viewModel.style.backgroundColor .ignoresSafeArea() - VStack(spacing: UIConstants.Spacing.medium) { - // Top overlay - if viewModel.style.showBattery || viewModel.style.showDate { - TopOverlayView( - showBattery: viewModel.style.showBattery, - showDate: viewModel.style.showDate, - color: viewModel.style.digitColor.opacity(UIConstants.Opacity.primary), - opacity: viewModel.style.overlayOpacity + GeometryReader { geometry in + ZStack { + // Time display - fills available space within safe areas + TimeDisplayView( + date: viewModel.currentTime, + use24Hour: viewModel.style.use24Hour, + showSeconds: viewModel.style.showSeconds, + digitColor: viewModel.style.digitColor, + glowIntensity: viewModel.style.glowIntensity, + manualScale: viewModel.style.digitScale, + stretched: viewModel.style.stretched, + showAmPmBadge: viewModel.style.showAmPmBadge, + clockOpacity: viewModel.style.clockOpacity, + fontFamily: viewModel.style.fontFamily, + fontWeight: viewModel.style.fontWeight, + fontDesign: viewModel.style.fontDesign ) - .padding(.top, UIConstants.Spacing.small) - .padding(.horizontal, UIConstants.Spacing.large) + .frame(width: geometry.size.width, height: geometry.size.height) .transition(.opacity) + + // Top overlay - positioned at top with safe area consideration + VStack { + if viewModel.style.showBattery || viewModel.style.showDate { + TopOverlayView( + showBattery: viewModel.style.showBattery, + showDate: viewModel.style.showDate, + color: viewModel.style.digitColor.opacity(UIConstants.Opacity.primary), + opacity: viewModel.style.overlayOpacity + ) + .padding(.top, UIConstants.Spacing.small) + .padding(.horizontal, UIConstants.Spacing.large) + .transition(.opacity) + } + Spacer() + } } - - Spacer() - - // Time display - TimeDisplayView( - date: viewModel.currentTime, - use24Hour: viewModel.style.use24Hour, - showSeconds: viewModel.style.showSeconds, - digitColor: viewModel.style.digitColor, - glowIntensity: viewModel.style.glowIntensity, - manualScale: viewModel.style.digitScale, - stretched: viewModel.style.stretched, - showAmPmBadge: viewModel.style.showAmPmBadge, - clockOpacity: viewModel.style.clockOpacity - ) - .padding(.horizontal, UIConstants.Spacing.medium) - .transition(.opacity) - - Spacer() + .scaleEffect(viewModel.isDisplayMode ? 1.0 : 0.995) + .animation(UIConstants.AnimationCurves.smooth, value: viewModel.isDisplayMode) } - .scaleEffect(viewModel.isDisplayMode ? 1.0 : 0.995) - .animation(UIConstants.AnimationCurves.smooth, value: viewModel.isDisplayMode) } .navigationTitle(viewModel.isDisplayMode ? "" : "Clock") .toolbar { @@ -72,6 +76,7 @@ struct ClockView: View { } .navigationBarBackButtonHidden(viewModel.isDisplayMode) .toolbar(viewModel.isDisplayMode ? .hidden : .automatic) + .statusBarHidden(viewModel.isDisplayMode) .onAppear { setTabBarHidden(viewModel.isDisplayMode, animated: false) } @@ -90,6 +95,7 @@ struct ClockView: View { .onEnded { _ in viewModel.toggleDisplayMode() setTabBarHidden(viewModel.isDisplayMode, animated: true) + // Status bar hiding is handled by the .statusBarHidden modifier } ) } diff --git a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift index a2ec8a6..6870d3d 100644 --- a/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift @@ -20,6 +20,9 @@ struct TimeDisplayView: View { let stretched: Bool let showAmPmBadge: Bool let clockOpacity: Double + let fontFamily: String + let fontWeight: String + let fontDesign: String // MARK: - Formatters private static let hour24DF: DateFormatter = { @@ -63,18 +66,29 @@ struct TimeDisplayView: View { let size = proxy.size let portrait = size.height >= size.width - // Use optimal font sizing that maximizes space usage + // Get safe area information to avoid Dynamic Island overlap + let safeAreaInsets = proxy.safeAreaInsets + let availableWidth = size.width - safeAreaInsets.leading - safeAreaInsets.trailing + let availableHeight = size.height - safeAreaInsets.top - safeAreaInsets.bottom + + // Use available space within safe areas for font sizing calculations + let availableSize = CGSize(width: availableWidth, height: availableHeight) + + // Use full GeometryReader size for frame (to fill entire space) + let fullScreenSize = size + + // Use optimal font sizing that maximizes space usage within safe areas let baseFontSize = stretched ? FontUtils.maximumStretchedFontSize( - containerWidth: size.width, - containerHeight: size.height, + containerWidth: availableSize.width, + containerHeight: availableSize.height, isPortrait: portrait, showSeconds: showSeconds, showAmPm: !use24Hour && showAmPmBadge ) : FontUtils.optimalFontSize( - containerWidth: size.width, - containerHeight: size.height, + containerWidth: availableSize.width, + containerHeight: availableSize.height, isPortrait: portrait, showSeconds: showSeconds, showAmPm: !use24Hour && showAmPmBadge @@ -89,8 +103,18 @@ struct TimeDisplayView: View { let showAMPM = !use24Hour && showAmPmBadge // Calculate sizes using fixed-width approach to prevent jumping - let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold) - let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold) + let digitUIFont = FontUtils.customUIFont( + size: baseFontSize, + family: fontFamily, + weight: fontWeight, + design: fontDesign + ) + let ampmUIFont = FontUtils.customUIFont( + size: ampmFontSize, + family: fontFamily, + weight: fontWeight, + design: fontDesign + ) // Use fixed-width calculations to prevent layout jumping let digitWidth = FontUtils.maxDigitWidth(font: digitUIFont) @@ -122,10 +146,10 @@ struct TimeDisplayView: View { showAMPM: showAMPM ) - // Calculate scale with maximum space utilization + // Calculate scale with maximum space utilization using available space let safeInset = AppConstants.Defaults.safeInset - let availableW = max(1, size.width - safeInset * 2) - let availableH = max(1, size.height - safeInset * 2) + let availableW = max(1, availableSize.width - safeInset * 2) + let availableH = max(1, availableSize.height - safeInset * 2) // Calculate scaling factors let widthScale = availableW / max(totalWidth, 1) @@ -142,16 +166,16 @@ struct TimeDisplayView: View { Group { if portrait { VStack(spacing: 0) { - TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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) + 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) } - TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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) - TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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() @@ -160,16 +184,16 @@ struct TimeDisplayView: View { } } else { HStack(spacing: 0) { - TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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) + 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) } - TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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) - TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity) + 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() @@ -178,7 +202,7 @@ struct TimeDisplayView: View { } } } - .frame(width: size.width, height: size.height, alignment: .center) + .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 @@ -258,20 +282,38 @@ private struct TimeSegment: View { 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 = UIFont.systemFont(ofSize: fontSize, weight: .bold) + let font = FontUtils.customUIFont( + size: fontSize, + family: fontFamily, + weight: fontWeight, + design: fontDesign + ) let maxWidth = FontUtils.maxDigitWidth(font: font) ZStack { Text(text) - .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .font(FontUtils.customFont( + size: fontSize, + family: fontFamily, + weight: fontWeight, + design: fontDesign + )) .foregroundColor(digitColor) .blur(radius: ColorUtils.glowRadius(intensity: glowIntensity)) .opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * clamped) Text(text) - .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .font(FontUtils.customFont( + size: fontSize, + family: fontFamily, + weight: fontWeight, + design: fontDesign + )) .foregroundColor(digitColor) .opacity(clamped) }