Compare commits

..

No commits in common. "301d31cb77a2dcc4ca837d342d6d8aba5cceaea7" and "a88a02b6710f3b95ce2b78a96ec825b5f23adf82" have entirely different histories.

24 changed files with 328 additions and 665 deletions

View File

@ -1,9 +0,0 @@
Use /ios-18-role
read the PRD.md
read the README.md
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
Try and use xcode build mcp if it is working and test using screenshots when asked.

21
PRD.md
View File

@ -19,19 +19,11 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Individual digit views** with consistent spacing and alignment - **Individual digit views** with consistent spacing and alignment
- **Font customization** with family, weight, and design selection - **Font customization** with family, weight, and design selection
- **Dynamic dot sizing** that matches selected font weight - **Dynamic dot sizing** that matches selected font weight
- **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad - **Safe area handling** with proper Dynamic Island avoidance
- **Full-screen mode** with status bar hiding and tab bar expansion - **Full-screen mode** with status bar hiding and tab bar expansion
- **Orientation-aware spacing** for optimal layout in all orientations - **Orientation-aware spacing** for optimal layout in all orientations
- **Modern iOS 18+ Animations**:
- **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch.
- **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles).
- **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change.
- **Glitch Effect**: High-energy digital jitter with random offsets and rapid opacity shifts.
- **Dynamic Glow Pulsing**: Glow intensity and blur radius pulse during digit transitions for enhanced visual feedback.
- **Breathing Colon Effect**: Subtle opacity pulsing for colon separators to add life to the display.
### 2. Clock Customization ### 2. Clock Customization
- **Selectable digit animation styles**: Choose from None, Spring, Bounce, and Glitch
- **Color customization**: User-selectable digit colors with color picker - **Color customization**: User-selectable digit colors with color picker
- **Background color**: Customizable background with color picker - **Background color**: Customizable background with color picker
- **Glow effects**: Adjustable glow intensity (0-100%) - **Glow effects**: Adjustable glow intensity (0-100%)
@ -52,7 +44,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding - **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) - **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
- **Smooth transitions**: Animated transitions between modes - **Smooth transitions**: Animated transitions between modes
- **Status bar control**: Status bar hidden on the Clock tab (including full-screen mode) - **Status bar control**: Status bar automatically hidden in full-screen mode
- **Safe area expansion**: Clock expands into tab bar area when hidden - **Safe area expansion**: Clock expands into tab bar area when hidden
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap - **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
- **Orientation handling**: Full-screen mode works in both portrait and landscape - **Orientation handling**: Full-screen mode works in both portrait and landscape
@ -144,13 +136,12 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Orientation-aware spacing**: Different spacing values for portrait vs landscape - **Orientation-aware spacing**: Different spacing values for portrait vs landscape
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds - **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
- **Dot weight matching**: Colon dots scale with selected font weight - **Dot weight matching**: Colon dots scale with selected font weight
- **Dot sizing balance**: Tuned dot sizing multipliers preserve readability while maximizing digit size in tight layouts
- **Overflow prevention**: Spacing calculations prevent content clipping - **Overflow prevention**: Spacing calculations prevent content clipping
- **Perfect centering**: All elements centered both horizontally and vertically - **Perfect centering**: All elements centered both horizontally and vertically
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views - **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views
### Full-Screen Mode Enhancements ### Full-Screen Mode Enhancements
- **Status bar hiding**: Status bar remains hidden while on the Clock tab - **Status bar hiding**: Automatic status bar hiding in full-screen mode
- **Tab bar expansion**: Clock expands into tab bar area when hidden - **Tab bar expansion**: Clock expands into tab bar area when hidden
- **Safe area management**: Proper handling of Dynamic Island and other safe areas - **Safe area management**: Proper handling of Dynamic Island and other safe areas
- **Smooth transitions**: Animated transitions between normal and full-screen modes - **Smooth transitions**: Animated transitions between normal and full-screen modes
@ -333,7 +324,6 @@ These principles are fundamental to the project's long-term success and must be
- **Navigation destinations**: Deep linking for alarm editing - **Navigation destinations**: Deep linking for alarm editing
- **Toolbar integration**: Settings and add buttons in navigation bars - **Toolbar integration**: Settings and add buttons in navigation bars
- **Sheet presentations**: Modal settings and alarm creation - **Sheet presentations**: Modal settings and alarm creation
- **Title presentation**: Inline titles on iPhone; titles hidden on iPad tab screens
### Visual Design ### Visual Design
- **Rounded corners**: Modern iOS design language - **Rounded corners**: Modern iOS design language
@ -566,14 +556,13 @@ The following changes **automatically require** PRD updates:
- Snooze duration settings - Snooze duration settings
### Noise Tab ### Noise Tab
1. **Sound Selection**: Browse sounds by category with system search in the navigation bar (iPhone and iPad) 1. **Sound Selection**: Browse sounds by category with search functionality
2. **Sound Preview**: Long-press for 3-second preview 2. **Sound Preview**: Long-press for 3-second preview
3. **Visual Feedback**: Grid layout with clear selection states 3. **Visual Feedback**: Grid layout with clear selection states
4. **Auto-stop**: Automatically stops current sound when selecting new one 4. **Auto-stop**: Automatically stops current sound when selecting new one
5. **Play/Stop Controls**: Simple button with visual feedback 5. **Play/Stop Controls**: Simple button with visual feedback
6. **Continuous playback**: Sounds loop until stopped 6. **Continuous playback**: Sounds loop until stopped
7. **Empty state guidance**: Prompt shown when no sound is selected 7. **Responsive layout**: Optimized for portrait and landscape orientations
8. **Responsive layout**: Optimized for portrait and landscape orientations
## Technical Requirements ## Technical Requirements

View File

@ -27,9 +27,6 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Auto-fit sizing and manual scale control - Auto-fit sizing and manual scale control
- Custom fonts, weights, and designs with live preview - Custom fonts, weights, and designs with live preview
- Glow and opacity controls for low-light comfort - Glow and opacity controls for low-light comfort
- Clock tab hides the status bar for a distraction-free display
- Selectable animation styles: None, Spring, Bounce, and Glitch
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
**White Noise** **White Noise**
- Multiple ambient categories and curated sound packs - Multiple ambient categories and curated sound packs

View File

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

View File

@ -52,17 +52,14 @@ struct ContentView: View {
ClockSettingsView(style: clockViewModel.style) { newStyle in ClockSettingsView(style: clockViewModel.style) { newStyle in
clockViewModel.updateStyle(newStyle) clockViewModel.updateStyle(newStyle)
} }
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
} }
.tabItem { .tabItem {
Label("Settings", systemImage: "gearshape") Label("Settings", systemImage: "gearshape")
} }
.tag(Tab.settings) .tag(Tab.settings)
} }
.onChange(of: selectedTab) { oldValue, newValue in
if oldValue == .clock && newValue != .clock {
clockViewModel.setDisplayMode(false)
}
}
.accentColor(AppAccent.primary) .accentColor(AppAccent.primary)
.background(Color.Branding.primary.ignoresSafeArea()) .background(Color.Branding.primary.ignoresSafeArea())
} }

View File

@ -18,7 +18,6 @@ struct AlarmView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
Group { Group {
if viewModel.alarms.isEmpty { if viewModel.alarms.isEmpty {
EmptyAlarmsView { EmptyAlarmsView {
@ -51,8 +50,7 @@ struct AlarmView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
} }
.navigationTitle(isPad ? "" : "Alarms") .navigationTitle("Alarms")
.navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {

View File

@ -23,6 +23,8 @@ class ClockStyle: Codable, Equatable {
var digitColorHex: String = AppConstants.Defaults.digitColorHex var digitColorHex: String = AppConstants.Defaults.digitColorHex
var randomizeColor: Bool = false var randomizeColor: Bool = false
var glowIntensity: Double = AppConstants.Defaults.glowIntensity var glowIntensity: Double = AppConstants.Defaults.glowIntensity
var digitScale: Double = AppConstants.Defaults.digitScale
var stretched: Bool = true
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
// MARK: - Color Theme Settings // MARK: - Color Theme Settings
@ -41,7 +43,6 @@ class ClockStyle: Codable, Equatable {
var fontFamily: FontFamily = .system var fontFamily: FontFamily = .system
var fontWeight: Font.Weight = .bold var fontWeight: Font.Weight = .bold
var fontDesign: Font.Design = .rounded var fontDesign: Font.Design = .rounded
var digitAnimationStyle: DigitAnimationStyle = .spring
// MARK: - Overlay Settings // MARK: - Overlay Settings
var showBattery: Bool = true var showBattery: Bool = true
@ -67,6 +68,8 @@ class ClockStyle: Codable, Equatable {
case digitColorHex case digitColorHex
case randomizeColor case randomizeColor
case glowIntensity case glowIntensity
case digitScale
case stretched
case backgroundHex case backgroundHex
case selectedColorTheme case selectedColorTheme
case nightModeEnabled case nightModeEnabled
@ -79,7 +82,6 @@ class ClockStyle: Codable, Equatable {
case fontFamily case fontFamily
case fontWeight case fontWeight
case fontDesign case fontDesign
case digitAnimationStyle
case showBattery case showBattery
case showDate case showDate
case dateFormat case dateFormat
@ -105,6 +107,8 @@ class ClockStyle: Codable, Equatable {
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity
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.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
self.selectedColorTheme = try container.decodeIfPresent(String.self, forKey: .selectedColorTheme) ?? self.selectedColorTheme self.selectedColorTheme = try container.decodeIfPresent(String.self, forKey: .selectedColorTheme) ?? self.selectedColorTheme
self.nightModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .nightModeEnabled) ?? self.nightModeEnabled self.nightModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .nightModeEnabled) ?? self.nightModeEnabled
@ -127,10 +131,6 @@ class ClockStyle: Codable, Equatable {
let decoded = Font.Design(rawValue: fontDesignString) { let decoded = Font.Design(rawValue: fontDesignString) {
self.fontDesign = decoded self.fontDesign = decoded
} }
if let animationStyleRaw = try container.decodeIfPresent(String.self, forKey: .digitAnimationStyle),
let decoded = DigitAnimationStyle(rawValue: animationStyleRaw) {
self.digitAnimationStyle = decoded
}
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
@ -151,6 +151,8 @@ class ClockStyle: Codable, Equatable {
try container.encode(digitColorHex, forKey: .digitColorHex) try container.encode(digitColorHex, forKey: .digitColorHex)
try container.encode(randomizeColor, forKey: .randomizeColor) try container.encode(randomizeColor, forKey: .randomizeColor)
try container.encode(glowIntensity, forKey: .glowIntensity) try container.encode(glowIntensity, forKey: .glowIntensity)
try container.encode(digitScale, forKey: .digitScale)
try container.encode(stretched, forKey: .stretched)
try container.encode(backgroundHex, forKey: .backgroundHex) try container.encode(backgroundHex, forKey: .backgroundHex)
try container.encode(selectedColorTheme, forKey: .selectedColorTheme) try container.encode(selectedColorTheme, forKey: .selectedColorTheme)
try container.encode(nightModeEnabled, forKey: .nightModeEnabled) try container.encode(nightModeEnabled, forKey: .nightModeEnabled)
@ -163,7 +165,6 @@ class ClockStyle: Codable, Equatable {
try container.encode(fontFamily.rawValue, forKey: .fontFamily) try container.encode(fontFamily.rawValue, forKey: .fontFamily)
try container.encode(fontWeight.rawValue, forKey: .fontWeight) try container.encode(fontWeight.rawValue, forKey: .fontWeight)
try container.encode(fontDesign.rawValue, forKey: .fontDesign) try container.encode(fontDesign.rawValue, forKey: .fontDesign)
try container.encode(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle)
try container.encode(showBattery, forKey: .showBattery) try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate) try container.encode(showDate, forKey: .showDate)
try container.encode(dateFormat, forKey: .dateFormat) try container.encode(dateFormat, forKey: .dateFormat)
@ -444,6 +445,8 @@ class ClockStyle: Codable, Equatable {
lhs.digitColorHex == rhs.digitColorHex && lhs.digitColorHex == rhs.digitColorHex &&
lhs.randomizeColor == rhs.randomizeColor && lhs.randomizeColor == rhs.randomizeColor &&
lhs.glowIntensity == rhs.glowIntensity && lhs.glowIntensity == rhs.glowIntensity &&
lhs.digitScale == rhs.digitScale &&
lhs.stretched == rhs.stretched &&
lhs.backgroundHex == rhs.backgroundHex && lhs.backgroundHex == rhs.backgroundHex &&
lhs.selectedColorTheme == rhs.selectedColorTheme && lhs.selectedColorTheme == rhs.selectedColorTheme &&
lhs.nightModeEnabled == rhs.nightModeEnabled && lhs.nightModeEnabled == rhs.nightModeEnabled &&
@ -456,7 +459,6 @@ class ClockStyle: Codable, Equatable {
lhs.fontFamily == rhs.fontFamily && lhs.fontFamily == rhs.fontFamily &&
lhs.fontWeight == rhs.fontWeight && lhs.fontWeight == rhs.fontWeight &&
lhs.fontDesign == rhs.fontDesign && lhs.fontDesign == rhs.fontDesign &&
lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
lhs.showBattery == rhs.showBattery && lhs.showBattery == rhs.showBattery &&
lhs.showDate == rhs.showDate && lhs.showDate == rhs.showDate &&
lhs.dateFormat == rhs.dateFormat && lhs.dateFormat == rhs.dateFormat &&

View File

@ -69,14 +69,6 @@ class ClockViewModel {
updateWakeLockState() updateWakeLockState()
} }
func setDisplayMode(_ enabled: Bool) {
guard isDisplayMode != enabled else { return }
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
isDisplayMode = enabled
}
updateWakeLockState()
}
func updateStyle(_ newStyle: ClockStyle) { func updateStyle(_ newStyle: ClockStyle) {
// Update properties of the existing style object instead of replacing it // Update properties of the existing style object instead of replacing it
@ -87,6 +79,8 @@ class ClockViewModel {
style.forceHorizontalMode = newStyle.forceHorizontalMode style.forceHorizontalMode = newStyle.forceHorizontalMode
style.digitColorHex = newStyle.digitColorHex style.digitColorHex = newStyle.digitColorHex
style.glowIntensity = newStyle.glowIntensity style.glowIntensity = newStyle.glowIntensity
style.digitScale = newStyle.digitScale
style.stretched = newStyle.stretched
style.clockOpacity = newStyle.clockOpacity style.clockOpacity = newStyle.clockOpacity
style.fontFamily = newStyle.fontFamily style.fontFamily = newStyle.fontFamily
style.fontWeight = newStyle.fontWeight style.fontWeight = newStyle.fontWeight
@ -105,7 +99,6 @@ class ClockViewModel {
style.nightModeEndTime = newStyle.nightModeEndTime style.nightModeEndTime = newStyle.nightModeEndTime
style.ambientLightThreshold = newStyle.ambientLightThreshold style.ambientLightThreshold = newStyle.ambientLightThreshold
style.autoBrightness = newStyle.autoBrightness style.autoBrightness = newStyle.autoBrightness
style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes style.respectFocusModes = newStyle.respectFocusModes

View File

@ -27,7 +27,6 @@ struct ClockSettingsView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
ScrollView { ScrollView {
VStack(spacing: Design.Spacing.xxLarge) { VStack(spacing: Design.Spacing.xxLarge) {
BasicAppearanceSection( BasicAppearanceSection(
@ -102,7 +101,7 @@ struct ClockSettingsView: View {
.padding(.bottom, Design.Spacing.xxxLarge) .padding(.bottom, Design.Spacing.xxxLarge)
} }
.background(AppSurface.primary) .background(AppSurface.primary)
.navigationTitle(isPad ? "" : "Settings") .navigationTitle("Clock Settings")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
digitColor = Color(hex: style.digitColorHex) ?? .white digitColor = Color(hex: style.digitColorHex) ?? .white

View File

@ -11,73 +11,45 @@ import Bedrock
/// Main clock display view with settings and display mode /// Main clock display view with settings and display mode
struct ClockView: View { struct ClockView: View {
// MARK: - Debug Configuration
private static let debugShowSafeAreas = false
// MARK: - Properties // MARK: - Properties
@Bindable var viewModel: ClockViewModel @Bindable var viewModel: ClockViewModel
@State private var showFullScreenHint = false
@State private var idleTimer: Timer? @State private var idleTimer: Timer?
@State private var didHandleTouch = false @State private var didHandleTouch = false
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
GeometryReader { geometry in
// When ignoring safe areas, geometry.size IS the full screen
let screenWidth = geometry.size.width
let screenHeight = geometry.size.height
let isLandscape = screenWidth > screenHeight
// Get safe area insets from UIWindow since GeometryReader ignores them
let windowInsets = Self.getWindowSafeAreaInsets()
let safeInsets = geometry.safeAreaInsets // May be 0 due to ignoresSafeArea
// Dynamic Island handling:
// In landscape, apply symmetric padding to keep content centered
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
let symmetricInset = isLandscape ? dynamicIslandInset : 0
ZStack { ZStack {
// Background extends to full screen
viewModel.style.effectiveBackgroundColor viewModel.style.effectiveBackgroundColor
.ignoresSafeArea()
// Main clock display container with symmetric padding for Dynamic Island GeometryReader { geometry in
ZStack {
// Main clock display container
ClockDisplayContainer( ClockDisplayContainer(
currentTime: viewModel.currentTime, currentTime: viewModel.currentTime,
style: viewModel.style, style: viewModel.style,
isDisplayMode: viewModel.isDisplayMode isDisplayMode: viewModel.isDisplayMode
) )
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset)
.debugBorder(Self.debugShowSafeAreas, color: .yellow, label: "ClockDisplayContainer")
// Top overlay container with symmetric padding // Top overlay container
ClockOverlayContainer(style: viewModel.style) ClockOverlayContainer(style: viewModel.style)
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset) // Full screen hint overlay
if showFullScreenHint {
FullScreenHintView(isDisplayMode: viewModel.isDisplayMode)
} }
.frame(width: screenWidth, height: screenHeight) }
.overlay(alignment: .bottomLeading) { }
if Self.debugShowSafeAreas { }
safeAreaDebugInfo( .ignoresSafeArea(.all, edges: viewModel.isDisplayMode ? .bottom : [])
size: geometry.size, .statusBarHidden(viewModel.isDisplayMode)
windowInsets: windowInsets, .overlay {
symmetricInset: symmetricInset // Toolbar overlay
ClockToolbar(
isDisplayMode: viewModel.isDisplayMode
) )
} }
}
.onAppear {
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
}
.onChange(of: geometry.size) { _, newSize in
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
}
.onChange(of: safeInsets) { _, newInsets in
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
}
}
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true)
.overlay { .overlay {
// Tab bar management overlay // Tab bar management overlay
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode) ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
@ -123,59 +95,18 @@ struct ClockView: View {
private func enterDisplayModeFromIdle() { private func enterDisplayModeFromIdle() {
guard !viewModel.isDisplayMode else { return } guard !viewModel.isDisplayMode else { return }
viewModel.toggleDisplayMode() viewModel.toggleDisplayMode()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showFullScreenHint = true
}
} }
private func handleUserInteraction() { private func handleUserInteraction() {
if viewModel.isDisplayMode { if viewModel.isDisplayMode {
viewModel.toggleDisplayMode() viewModel.toggleDisplayMode()
showFullScreenHint = false
} }
resetIdleTimer() resetIdleTimer()
} }
// MARK: - Safe Area Helpers
/// Get safe area insets from the key window (works even when ignoring safe areas)
private static func getWindowSafeAreaInsets() -> UIEdgeInsets {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first else {
return .zero
}
return window.safeAreaInsets
}
// MARK: - Debug Views
@ViewBuilder
private func safeAreaDebugInfo(
size: CGSize,
windowInsets: UIEdgeInsets,
symmetricInset: CGFloat
) -> some View {
let isLandscape = size.width > size.height
let hasDynamicIsland = windowInsets.left > 0 || windowInsets.right > 0
VStack(alignment: .leading, spacing: 2) {
Text("Screen: \(Int(size.width))×\(Int(size.height))")
Text("Window Insets: L:\(Int(windowInsets.left)) R:\(Int(windowInsets.right)) T:\(Int(windowInsets.top)) B:\(Int(windowInsets.bottom))")
Text("Symmetric Inset: \(Int(symmetricInset))")
Text("Dynamic Island: \(hasDynamicIsland && isLandscape ? "Yes" : "No")")
Text("Orientation: \(isLandscape ? "Landscape" : "Portrait")")
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(.green)
.padding(4)
.background(Color.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 4))
.padding(8)
}
// MARK: - Debug Logging
private func logClockLayout(size: CGSize, safeAreaInsets: EdgeInsets) {
let isLandscape = size.width > size.height
let safeInset = max(safeAreaInsets.leading, safeAreaInsets.trailing)
let symmetricInset = isLandscape ? safeInset : 0
Design.debugLog("[clockLayout] size=\(String(format: "%.1f", size.width))x\(String(format: "%.1f", size.height))")
Design.debugLog("[clockLayout] insets=(t:\(String(format: "%.1f", safeAreaInsets.top)), l:\(String(format: "%.1f", safeAreaInsets.leading)), b:\(String(format: "%.1f", safeAreaInsets.bottom)), r:\(String(format: "%.1f", safeAreaInsets.trailing)))")
Design.debugLog("[clockLayout] isLandscape=\(isLandscape), safeInset=\(String(format: "%.1f", safeInset)), symmetricInset=\(String(format: "%.1f", symmetricInset))")
}
} }
// MARK: - Preview // MARK: - Preview

View File

@ -18,12 +18,19 @@ struct ClockDisplayContainer: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
GeometryReader { geometry in return GeometryReader { geometry in
let isPortrait = geometry.size.height >= geometry.size.width let isPortrait = geometry.size.height >= geometry.size.width
let hasOverlay = style.showBattery || style.showDate let hasOverlay = style.showBattery || style.showDate
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0 let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
// Time display - fills all available space VStack(spacing: 0) {
// Top spacing to account for overlay
if hasOverlay {
Spacer()
.frame(height: topSpacing)
}
// Time display - fills remaining space
TimeDisplayView( TimeDisplayView(
date: currentTime, date: currentTime,
use24Hour: style.use24Hour, use24Hour: style.use24Hour,
@ -31,17 +38,19 @@ struct ClockDisplayContainer: View {
showAmPm: style.showAmPm, showAmPm: style.showAmPm,
digitColor: style.effectiveDigitColor, digitColor: style.effectiveDigitColor,
glowIntensity: style.glowIntensity, glowIntensity: style.glowIntensity,
manualScale: style.digitScale,
stretched: style.stretched,
clockOpacity: style.clockOpacity, clockOpacity: style.clockOpacity,
fontFamily: style.fontFamily, fontFamily: style.fontFamily,
fontWeight: style.fontWeight, fontWeight: style.fontWeight,
fontDesign: style.fontDesign, fontDesign: style.fontDesign,
forceHorizontalMode: style.forceHorizontalMode, forceHorizontalMode: style.forceHorizontalMode,
isDisplayMode: isDisplayMode, isDisplayMode: isDisplayMode
animationStyle: style.digitAnimationStyle
) )
.padding(.top, topSpacing)
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity) .transition(.opacity)
Spacer()
}
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode) .animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
} }
} }

View File

@ -15,10 +15,8 @@ struct ClockToolbar: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
EmptyView() EmptyView()
.navigationTitle(isDisplayMode || isPad ? "" : "Clock") .navigationTitle(isDisplayMode ? "" : "Clock")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(isDisplayMode) .navigationBarBackButtonHidden(isDisplayMode)
.toolbar(isDisplayMode ? .hidden : .automatic) .toolbar(isDisplayMode ? .hidden : .automatic)
} }

View File

@ -35,12 +35,6 @@ struct ColonView: View {
} }
.fixedSize(horizontal: true, vertical: true) .fixedSize(horizontal: true, vertical: true)
.accessibilityHidden(true) .accessibilityHidden(true)
.phaseAnimator([0, 1]) { content, phase in
content
.opacity(phase == 1 ? 1.0 : 0.6)
} animation: { _ in
.easeInOut(duration: 1.0)
}
} }
} }

View File

@ -6,13 +6,10 @@
// //
import SwiftUI import SwiftUI
import Bedrock
/// Component for displaying a single digit with fixed width and glow effects /// Component for displaying a single digit with fixed width and glow effects
struct DigitView: View { struct DigitView: View {
@Environment(\.sizeCategory) private var sizeCategory
// MARK: - Debug Configuration
private static let debugShowBorders = false
let digit: String let digit: String
let fontName: FontFamily let fontName: FontFamily
@ -22,9 +19,11 @@ struct DigitView: View {
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let isDisplayMode: Bool let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
@Binding var fontSize: CGFloat @Binding var fontSize: CGFloat
@State private var lastCalculatedSize: CGSize = .zero
@State private var isCalculating: Bool = false
init(digit: String, init(digit: String,
fontName: FontFamily, fontName: FontFamily,
weight: Font.Weight = .regular, weight: Font.Weight = .regular,
@ -33,8 +32,7 @@ struct DigitView: View {
opacity: Double = 1, opacity: Double = 1,
glowIntensity: Double = 0, glowIntensity: Double = 0,
fontSize: Binding<CGFloat>, fontSize: Binding<CGFloat>,
isDisplayMode: Bool = false, isDisplayMode: Bool = false) {
animationStyle: DigitAnimationStyle = .spring) {
self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0" self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0"
self.fontName = fontName self.fontName = fontName
self.weight = weight self.weight = weight
@ -43,16 +41,18 @@ struct DigitView: View {
self.digitColor = digitColor self.digitColor = digitColor
self.glowIntensity = glowIntensity self.glowIntensity = glowIntensity
self.isDisplayMode = isDisplayMode self.isDisplayMode = isDisplayMode
self.animationStyle = animationStyle
self._fontSize = fontSize self._fontSize = fontSize
} }
var body: some View { var body: some View {
GeometryReader { geometry in
ZStack { ZStack {
glowText glowText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
mainText mainText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private var glowRadius: CGFloat { private var glowRadius: CGFloat {
@ -64,106 +64,75 @@ struct DigitView: View {
} }
private var glowText: some View { private var glowText: some View {
baseText text
.foregroundColor(digitColor) .foregroundColor(digitColor)
.blur(radius: glowRadius) .blur(radius: glowRadius)
.opacity(glowOpacity) .opacity(glowOpacity)
.modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity))
} }
private var mainText: some View { private var mainText: some View {
baseText text
.foregroundColor(digitColor) .foregroundColor(digitColor)
.opacity(opacity) .opacity(opacity)
.modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize))
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
} }
private var baseText: some View { private var text: some View {
GeometryReader { geometry in
Text(digit) Text(digit)
.font(FontUtils.createFont(name: fontName, .font(FontUtils.createFont(name: fontName,
weight: weight, weight: weight,
design: design, design: design,
size: fontSize)) size: fontSize))
.lineLimit(1) .frame(maxWidth: .infinity, maxHeight: .infinity)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.1)
.lineSpacing(0)
.padding(.vertical, 0)
.padding(.horizontal, 0)
.baselineOffset(0)
.onAppear {
calculateOptimalFontSize(for: geometry.size)
} }
.onChange(of: geometry.size) { _, newSize in
calculateOptimalFontSize(for: newSize)
} }
.onChange(of: sizeCategory) { _, _ in
// MARK: - Animation Modifiers calculateOptimalFontSize(for: geometry.size)
private struct DigitAnimationModifier: ViewModifier {
let style: DigitAnimationStyle
let digit: String
let fontSize: CGFloat
func body(content: Content) -> some View {
switch style {
case .none:
content
case .spring:
content
.contentTransition(.numericText())
.animation(.snappy(duration: 0.35), value: digit)
.phaseAnimator([0, 1], trigger: digit) { content, phase in
content
.scaleEffect(phase == 1 ? 1.05 : 1.0)
.offset(y: phase == 1 ? -fontSize * 0.02 : 0)
} animation: { _ in
.spring(duration: 0.3, bounce: 0.4)
} }
case .bounce: .onChange(of: fontName) { _, _ in
content calculateOptimalFontSize(for: geometry.size)
.contentTransition(.numericText())
.animation(.bouncy(duration: 0.4), value: digit)
.phaseAnimator([0, 1], trigger: digit) { content, phase in
content
.scaleEffect(phase == 1 ? 1.1 : 1.0)
} animation: { _ in
.spring(duration: 0.4, bounce: 0.5)
} }
case .glitch: .onChange(of: weight) { _, _ in
content calculateOptimalFontSize(for: geometry.size)
.contentTransition(.numericText())
.phaseAnimator([0, 1, 2, 0], trigger: digit) { content, phase in
content
.offset(x: phase == 1 ? -fontSize * 0.05 : (phase == 2 ? fontSize * 0.05 : 0))
.opacity(phase == 1 || phase == 2 ? 0.7 : 1.0)
.scaleEffect(phase == 1 ? 1.02 : (phase == 2 ? 0.98 : 1.0))
} animation: { _ in
.interactiveSpring(response: 0.1, dampingFraction: 0.8)
} }
.onChange(of: design) { _, _ in
calculateOptimalFontSize(for: geometry.size)
} }
} }
} }
private struct GlowAnimationModifier: ViewModifier { private func calculateOptimalFontSize(for size: CGSize) {
let style: DigitAnimationStyle // Prevent multiple calculations for the same size
let digit: String guard size != lastCalculatedSize && !isCalculating else { return }
let glowRadius: CGFloat
let glowOpacity: Double
func body(content: Content) -> some View { // Prevent multiple updates per frame
switch style { guard !isCalculating else { return }
case .none: isCalculating = true
content
case .spring, .bounce: let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit,
content fontName: fontName,
.phaseAnimator([0, 1], trigger: digit) { content, phase in weight: weight,
content design: design,
.blur(radius: glowRadius * (phase == 1 ? 1.5 : 1.0)) for: size,
.opacity(glowOpacity * (phase == 1 ? 1.2 : 1.0)) isDisplayMode: isDisplayMode)
} animation: { _ in
.easeInOut(duration: 0.3) // Only update if the size is significantly different to prevent micro-adjustments
} fontSize = optimalSize
case .glitch: lastCalculatedSize = size
content
.phaseAnimator([0, 1, 2, 0], trigger: digit) { content, phase in // Reset calculation flag after a brief delay to allow for frame completion
content DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { // ~60fps
.opacity(phase == 1 || phase == 2 ? 0.4 : 1.0) isCalculating = false
.blur(radius: glowRadius * (phase == 1 || phase == 2 ? 2.0 : 1.0))
} animation: { _ in
.easeInOut(duration: 0.1)
}
} }
} }
} }
@ -182,8 +151,7 @@ private struct GlowAnimationModifier: ViewModifier {
weight: weight, weight: weight,
design: design, design: design,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontSize: $sharedFontSize, fontSize: $sharedFontSize)
animationStyle: .spring)
.border(Color.black) .border(Color.black)
DigitView(digit: "1", DigitView(digit: "1",
@ -191,8 +159,7 @@ private struct GlowAnimationModifier: ViewModifier {
weight: weight, weight: weight,
design: design, design: design,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontSize: $sharedFontSize, fontSize: $sharedFontSize)
animationStyle: .spring)
.border(Color.black) .border(Color.black)
Text(":") Text(":")
@ -204,8 +171,7 @@ private struct GlowAnimationModifier: ViewModifier {
weight: weight, weight: weight,
design: design, design: design,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontSize: $sharedFontSize, fontSize: $sharedFontSize)
animationStyle: .spring)
.border(Color.black) .border(Color.black)
DigitView(digit: "5", DigitView(digit: "5",
@ -213,8 +179,7 @@ private struct GlowAnimationModifier: ViewModifier {
weight: weight, weight: weight,
design: design, design: design,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontSize: $sharedFontSize, fontSize: $sharedFontSize)
animationStyle: .spring)
.border(Color.black) .border(Color.black)
} }
} }

View File

@ -0,0 +1,73 @@
//
// FullScreenHintView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/10/25.
//
import SwiftUI
/// Component that shows a subtle hint for how to exit full-screen mode
struct FullScreenHintView: View {
// MARK: - Properties
let isDisplayMode: Bool
@State private var hintOpacity: Double = 0.0
// MARK: - Body
var body: some View {
if isDisplayMode {
VStack {
Spacer()
HStack {
Image(systemName: "hand.point.up.left")
.font(.title2)
.foregroundColor(.white.opacity(0.7))
Text("Long press to exit full screen")
.font(.headline)
.foregroundColor(.white.opacity(0.7))
Image(systemName: "hand.point.up.left")
.font(.title2)
.foregroundColor(.white.opacity(0.7))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(.black.opacity(0.6))
.blur(radius: 1)
)
.opacity(hintOpacity)
Spacer()
.frame(height: 100) // Space above tab bar area
}
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.onAppear {
withAnimation(.easeInOut(duration: 0.5)) {
hintOpacity = 1.0
}
// Auto-hide after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
withAnimation(.easeInOut(duration: 0.5)) {
hintOpacity = 0.0
}
}
}
}
}
}
// MARK: - Preview
#Preview {
ZStack {
Color.black.ignoresSafeArea()
FullScreenHintView(isDisplayMode: true)
}
}

View File

@ -20,21 +20,6 @@ struct AdvancedAppearanceSection: View {
) )
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text("Digit Animation")
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
Picker("Digit Animation", selection: $style.digitAnimationStyle) {
ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in
Text(animation.displayName).tag(animation)
}
}
.pickerStyle(.menu)
.tint(AppAccent.primary)
}
.padding(.vertical, Design.Spacing.xSmall)
SettingsToggle( SettingsToggle(
title: "Randomize Color", title: "Randomize Color",
subtitle: "Shift the color every minute", subtitle: "Shift the color every minute",
@ -42,6 +27,25 @@ struct AdvancedAppearanceSection: View {
accentColor: AppAccent.primary accentColor: AppAccent.primary
) )
SettingsToggle(
title: "Stretched (AutoFit)",
subtitle: "Fit the clock to available space",
isOn: $style.stretched,
accentColor: AppAccent.primary
)
if !style.stretched {
SettingsSlider(
title: "Size",
subtitle: "Manual scaling when autofit is off",
value: $style.digitScale,
in: 0.0...1.0,
step: 0.01,
format: SliderFormat.percentage,
accentColor: AppAccent.primary
)
}
SettingsSlider( SettingsSlider(
title: "Glow", title: "Glow",
subtitle: "Adjust the glow intensity", subtitle: "Adjust the glow intensity",

View File

@ -11,18 +11,6 @@ import Bedrock
/// Component for displaying segmented time with customizable formatting /// Component for displaying segmented time with customizable formatting
struct TimeDisplayView: View { struct TimeDisplayView: View {
// MARK: - Debug Configuration
/// When true, shows static "88:88:88" to debug font sizing
private static let debugStaticTime = false
/// When true, shows debug overlays with sizing info
private static let debugShowOverlays = false
/// Static hour value for debug mode
private static let debugHour = "88"
/// Static minute value for debug mode
private static let debugMinute = "88"
/// Static seconds value for debug mode
private static let debugSeconds = "88"
// MARK: - Properties // MARK: - Properties
let date: Date let date: Date
let use24Hour: Bool let use24Hour: Bool
@ -30,22 +18,15 @@ struct TimeDisplayView: View {
let showAmPm: Bool let showAmPm: Bool
let digitColor: Color let digitColor: Color
let glowIntensity: Double let glowIntensity: Double
let manualScale: Double
let stretched: Bool
let clockOpacity: Double let clockOpacity: Double
let fontFamily: FontFamily let fontFamily: FontFamily
let fontWeight: Font.Weight let fontWeight: Font.Weight
let fontDesign: Font.Design let fontDesign: Font.Design
let forceHorizontalMode: Bool let forceHorizontalMode: Bool
let isDisplayMode: Bool let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
@State var fontSize: CGFloat = 100 @State var fontSize: CGFloat = 100
@State private var lastCalculatedContainerSize: CGSize = .zero
// MARK: - Layout Constants
private enum Layout {
static let dotDiameterMultiplier: CGFloat = 0.65
static let dotSpacingPortraitMultiplier: CGFloat = 0.16
static let dotSpacingLandscapeMultiplier: CGFloat = 0.22
}
// MARK: - Formatters // MARK: - Formatters
private static let hour24DF: DateFormatter = { private static let hour24DF: DateFormatter = {
@ -90,73 +71,49 @@ struct TimeDisplayView: View {
let portraitMode = containerSize.height >= containerSize.width let portraitMode = containerSize.height >= containerSize.width
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
// Time components - use debug static values if enabled // Time components
let hour: String let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
let minute: String let minute = Self.minuteDF.string(from: date)
let secondsText: String let secondsText = Self.secondDF.string(from: date)
if Self.debugStaticTime {
hour = Self.debugHour
minute = Self.debugMinute
secondsText = Self.debugSeconds
} else {
hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
minute = Self.minuteDF.string(from: date)
secondsText = Self.secondDF.string(from: date)
}
// AM/PM badge - hide in debug mode // AM/PM badge
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour let shouldShowAmPm = showAmPm && !use24Hour
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased() let amPmText = Self.amPmDF.string(from: date).uppercased()
// Separators - reasonable spacing with extra padding in landscape // Separators - reasonable spacing with extra padding in landscape
let dotDiameter = fontSize * Layout.dotDiameterMultiplier let dotDiameter = fontSize * 0.75
let dotSpacing = portrait let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
? fontSize * Layout.dotSpacingPortraitMultiplier
: fontSize * Layout.dotSpacingLandscapeMultiplier // Simple scaling - let the content size naturally and apply manual scale
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
// Time display with consistent centering and stable layout // Time display with consistent centering and stable layout
return Group { return Group {
if portrait { if portrait {
VStack(alignment: .center, spacing: 0) { VStack(alignment: .center) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
if showSeconds { if showSeconds {
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle) TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
} }
} }
.frame(maxWidth: .infinity) // Center horizontally in portrait
} else { } else {
// Landscape mode - use fixed digit widths for stable layout HStack(alignment: .center) {
// Each digit gets the same width (based on "8") so clock doesn't shift TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
let fixedDigitWidth = FontUtils.digitWidth(
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
fontSize: fontSize
)
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
HStack(alignment: .center, spacing: 0) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
.frame(width: segmentWidth)
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
.frame(width: dotDiameter) TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
.frame(width: segmentWidth)
if showSeconds { if showSeconds {
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false) ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
.frame(width: dotDiameter) TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
.frame(width: segmentWidth)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) // Center in landscape .frame(maxWidth: .infinity)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if shouldShowAmPm { if shouldShowAmPm {
Text(amPmText) Text(amPmText)
@ -175,153 +132,18 @@ struct TimeDisplayView: View {
} }
} }
.offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode .offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode
.scaleEffect(finalScale, anchor: .center)
.animation(.smooth(duration: Design.Animation.standard), value: finalScale)
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle .animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
.minimumScaleFactor(0.1) .minimumScaleFactor(0.1)
.clipped() // Prevent overflow beyond bounds .clipped() // Prevent overflow beyond bounds
.overlay(alignment: .topLeading) {
if Self.debugShowOverlays {
debugInfoOverlay(containerSize: containerSize, portrait: portrait)
}
}
.debugBorder(Self.debugShowOverlays, color: .cyan, label: "TimeDisplayView")
.onAppear {
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
.onChange(of: containerSize) { _, newSize in
updateFontSize(containerSize: newSize, portrait: portrait, showSeconds: showSeconds)
}
.onChange(of: showSeconds) { _, _ in
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
.onChange(of: fontFamily) { _, _ in
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
.onChange(of: fontWeight) { _, _ in
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
.onChange(of: fontDesign) { _, _ in
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
} }
//.border(.yellow, width: 1)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onOrientationChange() // Force updates on orientation changes .onOrientationChange() // Force updates on orientation changes
} }
// MARK: - Debug Overlay
@ViewBuilder
private func debugInfoOverlay(containerSize: CGSize, portrait: Bool) -> some View {
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
let colonSize = fontSize * Layout.dotDiameterMultiplier
let availableWidth = portrait
? containerSize.width
: max(1, containerSize.width - (colonCount * colonSize))
let availableHeight = portrait
? max(1, containerSize.height - (colonCount * colonSize))
: containerSize.height
let digitSlotSize = CGSize(
width: max(1, availableWidth / digitColumns),
height: max(1, availableHeight / digitRows)
)
VStack(alignment: .leading, spacing: 2) {
Text("Container: \(Int(containerSize.width))×\(Int(containerSize.height))")
Text("Digit Slot: \(Int(digitSlotSize.width))×\(Int(digitSlotSize.height))")
Text("Font Size: \(Int(fontSize))")
Text("Cols: \(Int(digitColumns)) Rows: \(Int(digitRows))")
Text("Portrait: \(portrait)")
Text("Family: \(fontFamily.rawValue)")
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(.yellow)
.padding(4)
.background(Color.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 4))
.padding(8)
}
// MARK: - Font Sizing
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
guard containerSize != .zero else { return }
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
let previousFontSize = fontSize
func calculateFontSize(reservingColonSize colonSize: CGFloat) -> CGFloat {
let availableWidth = portrait
? containerSize.width
: max(1, containerSize.width - (colonCount * colonSize))
let availableHeight = portrait
? max(1, containerSize.height - (colonCount * colonSize))
: containerSize.height
let digitSize = CGSize(
width: max(1, availableWidth / digitColumns),
height: max(1, availableHeight / digitRows)
)
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
return FontUtils.calculateOptimalFontSize(
digit: "8",
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
for: digitSize,
isDisplayMode: isDisplayMode
)
}
var estimated = calculateFontSize(reservingColonSize: 0)
for _ in 0..<2 {
let dotDiameter = estimated * Layout.dotDiameterMultiplier
estimated = calculateFontSize(reservingColonSize: dotDiameter)
}
if !portrait {
// Verify colon height fits
let dotDiameter = estimated * Layout.dotDiameterMultiplier
let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
let colonHeight = (dotDiameter * 2) + dotSpacing
if colonHeight > containerSize.height {
estimated *= containerSize.height / colonHeight
}
// CRITICAL: Verify total width fits to prevent clipping
// Calculate actual widths that will be used in layout
let actualDigitWidth = FontUtils.digitWidth(
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
fontSize: estimated
)
let actualColonWidth = estimated * Layout.dotDiameterMultiplier
let segmentCount: CGFloat = showSeconds ? 3.0 : 2.0
let totalWidth = (actualDigitWidth * 2 * segmentCount) + (colonCount * actualColonWidth)
// If total width exceeds container, scale down
if totalWidth > containerSize.width {
let scaleFactor = containerSize.width / totalWidth
estimated *= scaleFactor * 0.98 // Add 2% margin
Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
}
}
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
if abs(estimated - fontSize) > 1 {
fontSize = estimated
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
} else {
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
}
lastCalculatedContainerSize = containerSize
}
} }
@ -334,13 +156,14 @@ struct TimeDisplayView: View {
showAmPm: true, showAmPm: true,
digitColor: .white, digitColor: .white,
glowIntensity: 0.2, glowIntensity: 0.2,
manualScale: 1.0,
stretched: true,
clockOpacity: 1.0, clockOpacity: 1.0,
fontFamily: .verdana, fontFamily: .verdana,
fontWeight: .medium, fontWeight: .medium,
fontDesign: .default, fontDesign: .default,
forceHorizontalMode: false, forceHorizontalMode: false,
isDisplayMode: false, isDisplayMode: false
animationStyle: .spring
) )
.background(Color.black) .background(Color.black)
} }

View File

@ -7,14 +7,9 @@
import SwiftUI import SwiftUI
import Foundation import Foundation
import Bedrock
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits /// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
struct TimeSegment: View { struct TimeSegment: View {
// MARK: - Debug Configuration
private static let debugShowBorders = false
let text: String let text: String
@Binding var fontSize: CGFloat @Binding var fontSize: CGFloat
let opacity: Double let opacity: Double
@ -24,18 +19,9 @@ struct TimeSegment: View {
let fontWeight: Font.Weight let fontWeight: Font.Weight
let fontDesign: Font.Design let fontDesign: Font.Design
let isDisplayMode: Bool let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
var body: some View { var body: some View {
// Use fixed digit width based on widest digit ("8") for stable layout HStack(alignment: .center, spacing: 0) {
let fixedDigitWidth = FontUtils.digitWidth(
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
fontSize: fontSize
)
return HStack(alignment: .center, spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in ForEach(Array(text.enumerated()), id: \.offset) { index, character in
DigitView( DigitView(
digit: String(character), digit: String(character),
@ -46,14 +32,13 @@ struct TimeSegment: View {
opacity: clampedOpacity, opacity: clampedOpacity,
glowIntensity: glowIntensity, glowIntensity: glowIntensity,
fontSize: $fontSize, fontSize: $fontSize,
isDisplayMode: isDisplayMode, isDisplayMode: isDisplayMode
animationStyle: animationStyle
) )
.frame(width: fixedDigitWidth) // Fixed width for stability //.border(.red, width: 1)
.debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)")
} }
} }
.debugBorder(Self.debugShowBorders, color: .green, label: "Segment") //.border(Color.green, width: 1)
.frame(maxHeight: .infinity)
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -74,8 +59,7 @@ struct TimeSegment: View {
fontFamily: .system, fontFamily: .system,
fontWeight: .regular, fontWeight: .regular,
fontDesign: .default, fontDesign: .default,
isDisplayMode: true, isDisplayMode: true
animationStyle: .spring
) )
.background(Color.black) .background(Color.black)
} }

View File

@ -16,7 +16,7 @@ struct SoundCategoryView: View {
let sounds: [Sound] let sounds: [Sound]
@Binding var selectedSound: Sound? @Binding var selectedSound: Sound?
@State private var selectedCategory: SoundCategory = .all @State private var selectedCategory: SoundCategory = .all
@Binding var searchText: String @State private var searchText: String = ""
@State private var viewModel = SoundViewModel() @State private var viewModel = SoundViewModel()
// MARK: - Computed Properties // MARK: - Computed Properties
@ -69,6 +69,9 @@ struct SoundCategoryView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
// Search Bar
searchBar
// Category Tabs // Category Tabs
categoryTabs categoryTabs
@ -80,6 +83,24 @@ struct SoundCategoryView: View {
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)
} }
// MARK: - Subviews
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search sounds...", text: $searchText)
.textFieldStyle(.plain)
.foregroundColor(.primary)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private var categoryTabs: some View { private var categoryTabs: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
@ -236,8 +257,7 @@ struct SoundCard: View {
Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"), Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"),
Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound") Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound")
], ],
selectedSound: .constant(nil), selectedSound: .constant(nil)
searchText: .constant("")
) )
.padding() .padding()
} }

View File

@ -22,11 +22,6 @@ struct NoiseView: View {
} }
} }
} }
@State private var searchText: String = ""
private var isPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@ -47,11 +42,6 @@ struct NoiseView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
.animation(.easeInOut(duration: 0.3), value: selectedSound) .animation(.easeInOut(duration: 0.3), value: selectedSound)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .automatic),
prompt: "Search sounds"
)
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -74,23 +64,13 @@ struct NoiseView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Fixed header // Fixed header
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
Text("Ambient Sounds")
.sectionTitleStyle()
// Playback controls - always visible when sound is selected // Playback controls - always visible when sound is selected
if selectedSound != nil { if selectedSound != nil {
soundControlView soundControlView
.centered() .centered()
} else {
// Placeholder when no sound selected
VStack(spacing: Design.Spacing.small) {
Image(systemName: "music.note")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("Select a sound to begin")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.large)
} }
} }
.contentPadding(horizontal: Design.Spacing.large) .contentPadding(horizontal: Design.Spacing.large)
@ -101,8 +81,7 @@ struct NoiseView: View {
ScrollView { ScrollView {
SoundCategoryView( SoundCategoryView(
sounds: viewModel.availableSounds, sounds: viewModel.availableSounds,
selectedSound: $selectedSound, selectedSound: $selectedSound
searchText: $searchText
) )
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
} }
@ -113,10 +92,8 @@ struct NoiseView: View {
HStack(spacing: Design.Spacing.large) { HStack(spacing: Design.Spacing.large) {
// Left side: Player controls // Left side: Player controls
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
if !isPad {
Text("Ambient Sounds") Text("Ambient Sounds")
.sectionTitleStyle() .sectionTitleStyle()
}
if selectedSound != nil { if selectedSound != nil {
soundControlView soundControlView
@ -146,8 +123,7 @@ struct NoiseView: View {
ScrollView { ScrollView {
SoundCategoryView( SoundCategoryView(
sounds: viewModel.availableSounds, sounds: viewModel.availableSounds,
selectedSound: $selectedSound, selectedSound: $selectedSound
searchText: $searchText
) )
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large) .contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
} }

View File

@ -1,25 +0,0 @@
//
// DigitAnimationStyle.swift
// TheNoiseClock
//
// Created by AI Agent on 2/1/26.
//
import SwiftUI
/// Available animation styles for clock digits
public enum DigitAnimationStyle: String, CaseIterable, Codable {
case none
case spring
case bounce
case glitch
public var displayName: String {
switch self {
case .none: return "None"
case .spring: return "Spring"
case .bounce: return "Bounce"
case .glitch: return "Glitch"
}
}
}

View File

@ -50,6 +50,7 @@ enum AppConstants {
static let digitColorHex = "#FFFFFF" static let digitColorHex = "#FFFFFF"
static let backgroundColorHex = "#000000" static let backgroundColorHex = "#000000"
static let glowIntensity = 0.6 static let glowIntensity = 0.6
static let digitScale = 1.0
static let clockOpacity = 0.5 static let clockOpacity = 0.5
static let overlayOpacity = 0.5 static let overlayOpacity = 0.5
static let minFontSize = 20.0 static let minFontSize = 20.0

View File

@ -31,11 +31,14 @@ enum FontFamily: String, CaseIterable {
} }
} }
/// Font-specific size adjustment factor (1.0 = no adjustment)
/// Note: With CoreText glyph measurement, these are no longer needed
var percentageDownsize: CGFloat { var percentageDownsize: CGFloat {
// All fonts now use 1.0 since we have accurate glyph measurement switch self {
return 1.0 case .system:
return 0.95
case .georgia:
return 0.90
default: return 1
}
} }
var fontWeights: [Font.Weight] { var fontWeights: [Font.Weight] {

View File

@ -8,17 +8,10 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import UIKit import UIKit
import CoreText
/// Font sizing and typography utilities /// Font sizing and typography utilities
struct FontUtils { struct FontUtils {
// MARK: - Debug Configuration
/// Safety margin to prevent clipping (1.0 = no margin, 0.98 = 2% margin)
private static let safetyMargin: CGFloat = 0.98
/// When true, logs font sizing calculations
private static let debugLogging = false
static func calculateOptimalFontSize( static func calculateOptimalFontSize(
digit: String, digit: String,
fontName: FontFamily, fontName: FontFamily,
@ -30,7 +23,7 @@ struct FontUtils {
var low: CGFloat = 1.0 var low: CGFloat = 1.0
var high: CGFloat = 2000.0 var high: CGFloat = 2000.0
while high - low > 0.5 { while high - low > 0.01 {
let mid = (low + high) / 2 let mid = (low + high) / 2
let testFont = createUIFont( let testFont = createUIFont(
name: fontName, name: fontName,
@ -38,7 +31,7 @@ struct FontUtils {
design: design, design: design,
size: mid size: mid
) )
let textSize = glyphBoundingBox(for: digit, withFont: testFont) let textSize = tightBoundingBox(for: digit, withFont: testFont)
if textSize.width <= size.width && textSize.height <= size.height { if textSize.width <= size.width && textSize.height <= size.height {
low = mid low = mid
@ -47,60 +40,9 @@ struct FontUtils {
} }
} }
// Apply minimal safety margin - removed excessive downsize factors // Apply more conservative sizing in full screen mode
let finalSize = low * safetyMargin let baseSize = low * fontName.percentageDownsize
return isDisplayMode ? baseSize * 0.95 : baseSize
if debugLogging {
let testFont = createUIFont(name: fontName, weight: weight, design: design, size: finalSize)
let glyphSize = glyphBoundingBox(for: digit, withFont: testFont)
print("[FontUtils] target=\(Int(size.width))×\(Int(size.height)) glyph=\(Int(glyphSize.width))×\(Int(glyphSize.height)) fontSize=\(Int(finalSize))")
}
return finalSize
}
/// Get the fixed width for a single digit at the given font size
/// Uses "8" as the reference since it's typically the widest digit
static func digitWidth(
fontName: FontFamily,
weight: Font.Weight,
design: Font.Design,
fontSize: CGFloat
) -> CGFloat {
let font = createUIFont(name: fontName, weight: weight, design: design, size: fontSize)
return glyphBoundingBox(for: "8", withFont: font).width
}
/// Get the text size using typographic bounds that match SwiftUI rendering
private static func glyphBoundingBox(
for text: String,
withFont font: UIFont
) -> CGSize {
// Get the glyph advances (actual width of each character)
let ctFont = font as CTFont
var glyphs = [CGGlyph](repeating: 0, count: text.count)
var characters = [UniChar](text.utf16)
CTFontGetGlyphsForCharacters(ctFont, &characters, &glyphs, text.count)
var advances = [CGSize](repeating: .zero, count: glyphs.count)
CTFontGetAdvancesForGlyphs(ctFont, .horizontal, glyphs, &advances, glyphs.count)
let totalWidth = advances.reduce(0) { $0 + $1.width }
// Use ascender height for proper top clearance
// This matches what SwiftUI Text renders more closely
let ascender = font.ascender
let descender = abs(font.descender)
// For digits, we don't have descenders, but SwiftUI still allocates space
// Use ascender + small fraction of descender for safety
let height = ascender + (descender * 0.3)
if debugLogging {
print("[FontUtils] text='\(text)' width=\(Int(totalWidth)) ascender=\(Int(ascender)) height=\(Int(height))")
}
return CGSize(width: ceil(totalWidth), height: ceil(height))
} }
static func createFont( static func createFont(
@ -236,13 +178,14 @@ private struct TestContentView: View {
showAmPm: true, showAmPm: true,
digitColor: .primary, digitColor: .primary,
glowIntensity: 0.5, glowIntensity: 0.5,
manualScale: 1.0,
stretched: false,
clockOpacity: 1.0, clockOpacity: 1.0,
fontFamily: font, fontFamily: font,
fontWeight: fontWeight, fontWeight: fontWeight,
fontDesign: fontDesign, fontDesign: fontDesign,
forceHorizontalMode: true, forceHorizontalMode: true,
isDisplayMode: false, isDisplayMode: false)
animationStyle: .spring)
TimeSegment( TimeSegment(
text: digit, text: digit,
@ -253,8 +196,7 @@ private struct TestContentView: View {
fontFamily: font, // FontFamily fontFamily: font, // FontFamily
fontWeight: fontWeight, fontWeight: fontWeight,
fontDesign: fontDesign, fontDesign: fontDesign,
isDisplayMode: false, isDisplayMode: false
animationStyle: .spring
) )
} }
.frame(width: 400, height: 200) .frame(width: 400, height: 200)
@ -320,8 +262,7 @@ private struct FontSampleCell: View {
fontFamily: font, // FontFamily fontFamily: font, // FontFamily
fontWeight: weight, fontWeight: weight,
fontDesign: design, fontDesign: design,
isDisplayMode: false, isDisplayMode: false)
animationStyle: .spring)
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.border(Color.black) .border(Color.black)
} }