Compare commits

...

10 Commits

24 changed files with 665 additions and 328 deletions

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
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,11 +19,19 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Individual digit views** with consistent spacing and alignment
- **Font customization** with family, weight, and design selection
- **Dynamic dot sizing** that matches selected font weight
- **Safe area handling** with proper Dynamic Island avoidance
- **Safe area handling** with proper Dynamic Island avoidance on iPhone and full-width layout on iPad
- **Full-screen mode** with status bar hiding and tab bar expansion
- **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
- **Selectable digit animation styles**: Choose from None, Spring, Bounce, and Glitch
- **Color customization**: User-selectable digit colors with color picker
- **Background color**: Customizable background with color picker
- **Glow effects**: Adjustable glow intensity (0-100%)
@ -44,7 +52,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
- **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
- **Status bar control**: Status bar hidden on the Clock tab (including full-screen mode)
- **Safe area expansion**: Clock expands into tab bar area when hidden
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
- **Orientation handling**: Full-screen mode works in both portrait and landscape
@ -136,12 +144,13 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Orientation-aware spacing**: Different spacing values for portrait vs landscape
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
- **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
- **Perfect centering**: All elements centered both horizontally and vertically
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views
### Full-Screen Mode Enhancements
- **Status bar hiding**: Automatic status bar hiding in full-screen mode
- **Status bar hiding**: Status bar remains hidden while on the Clock tab
- **Tab bar expansion**: Clock expands into tab bar area when hidden
- **Safe area management**: Proper handling of Dynamic Island and other safe areas
- **Smooth transitions**: Animated transitions between normal and full-screen modes
@ -324,6 +333,7 @@ These principles are fundamental to the project's long-term success and must be
- **Navigation destinations**: Deep linking for alarm editing
- **Toolbar integration**: Settings and add buttons in navigation bars
- **Sheet presentations**: Modal settings and alarm creation
- **Title presentation**: Inline titles on iPhone; titles hidden on iPad tab screens
### Visual Design
- **Rounded corners**: Modern iOS design language
@ -556,13 +566,14 @@ The following changes **automatically require** PRD updates:
- Snooze duration settings
### Noise Tab
1. **Sound Selection**: Browse sounds by category with search functionality
1. **Sound Selection**: Browse sounds by category with system search in the navigation bar (iPhone and iPad)
2. **Sound Preview**: Long-press for 3-second preview
3. **Visual Feedback**: Grid layout with clear selection states
4. **Auto-stop**: Automatically stops current sound when selecting new one
5. **Play/Stop Controls**: Simple button with visual feedback
6. **Continuous playback**: Sounds loop until stopped
7. **Responsive layout**: Optimized for portrait and landscape orientations
7. **Empty state guidance**: Prompt shown when no sound is selected
8. **Responsive layout**: Optimized for portrait and landscape orientations
## Technical Requirements

View File

@ -27,6 +27,9 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- Auto-fit sizing and manual scale control
- Custom fonts, weights, and designs with live preview
- 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**
- Multiple ambient categories and curated sound packs

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,14 @@ class ClockViewModel {
// Manage wake lock based on display mode and keep awake setting
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) {
@ -79,8 +87,6 @@ class ClockViewModel {
style.forceHorizontalMode = newStyle.forceHorizontalMode
style.digitColorHex = newStyle.digitColorHex
style.glowIntensity = newStyle.glowIntensity
style.digitScale = newStyle.digitScale
style.stretched = newStyle.stretched
style.clockOpacity = newStyle.clockOpacity
style.fontFamily = newStyle.fontFamily
style.fontWeight = newStyle.fontWeight
@ -99,6 +105,7 @@ class ClockViewModel {
style.nightModeEndTime = newStyle.nightModeEndTime
style.ambientLightThreshold = newStyle.ambientLightThreshold
style.autoBrightness = newStyle.autoBrightness
style.digitAnimationStyle = newStyle.digitAnimationStyle
style.dateFormat = newStyle.dateFormat
style.respectFocusModes = newStyle.respectFocusModes

View File

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

View File

@ -11,45 +11,73 @@ import Bedrock
/// Main clock display view with settings and display mode
struct ClockView: View {
// MARK: - Debug Configuration
private static let debugShowSafeAreas = false
// MARK: - Properties
@Bindable var viewModel: ClockViewModel
@State private var showFullScreenHint = false
@State private var idleTimer: Timer?
@State private var didHandleTouch = false
// MARK: - Body
var body: some View {
ZStack {
viewModel.style.effectiveBackgroundColor
.ignoresSafeArea()
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
GeometryReader { geometry in
ZStack {
// Main clock display container
ClockDisplayContainer(
currentTime: viewModel.currentTime,
style: viewModel.style,
isDisplayMode: viewModel.isDisplayMode
// 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 {
// Background extends to full screen
viewModel.style.effectiveBackgroundColor
// Main clock display container with symmetric padding for Dynamic Island
ClockDisplayContainer(
currentTime: viewModel.currentTime,
style: viewModel.style,
isDisplayMode: viewModel.isDisplayMode
)
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset)
.debugBorder(Self.debugShowSafeAreas, color: .yellow, label: "ClockDisplayContainer")
// Top overlay container with symmetric padding
ClockOverlayContainer(style: viewModel.style)
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset)
}
.frame(width: screenWidth, height: screenHeight)
.overlay(alignment: .bottomLeading) {
if Self.debugShowSafeAreas {
safeAreaDebugInfo(
size: geometry.size,
windowInsets: windowInsets,
symmetricInset: symmetricInset
)
// Top overlay container
ClockOverlayContainer(style: viewModel.style)
// Full screen hint overlay
if showFullScreenHint {
FullScreenHintView(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(.all, edges: viewModel.isDisplayMode ? .bottom : [])
.statusBarHidden(viewModel.isDisplayMode)
.overlay {
// Toolbar overlay
ClockToolbar(
isDisplayMode: viewModel.isDisplayMode
)
}
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true)
.overlay {
// Tab bar management overlay
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
@ -95,18 +123,59 @@ struct ClockView: View {
private func enterDisplayModeFromIdle() {
guard !viewModel.isDisplayMode else { return }
viewModel.toggleDisplayMode()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showFullScreenHint = true
}
}
private func handleUserInteraction() {
if viewModel.isDisplayMode {
viewModel.toggleDisplayMode()
showFullScreenHint = false
}
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

View File

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

View File

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

View File

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

View File

@ -1,73 +0,0 @@
//
// 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,6 +20,21 @@ struct AdvancedAppearanceSection: View {
)
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(
title: "Randomize Color",
subtitle: "Shift the color every minute",
@ -27,25 +42,6 @@ struct AdvancedAppearanceSection: View {
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(
title: "Glow",
subtitle: "Adjust the glow intensity",

View File

@ -11,6 +11,18 @@ import Bedrock
/// Component for displaying segmented time with customizable formatting
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
let date: Date
let use24Hour: Bool
@ -18,15 +30,22 @@ struct TimeDisplayView: View {
let showAmPm: Bool
let digitColor: Color
let glowIntensity: Double
let manualScale: Double
let stretched: Bool
let clockOpacity: Double
let fontFamily: FontFamily
let fontWeight: Font.Weight
let fontDesign: Font.Design
let forceHorizontalMode: Bool
let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
@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
private static let hour24DF: DateFormatter = {
@ -71,49 +90,73 @@ struct TimeDisplayView: View {
let portraitMode = containerSize.height >= containerSize.width
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
// 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)
// Time components - use debug static values if enabled
let hour: String
let minute: String
let secondsText: String
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
let shouldShowAmPm = showAmPm && !use24Hour
let amPmText = Self.amPmDF.string(from: date).uppercased()
// AM/PM badge - hide in debug mode
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
// Separators - reasonable spacing with extra padding in landscape
let dotDiameter = fontSize * 0.75
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
// Simple scaling - let the content size naturally and apply manual scale
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
let dotDiameter = fontSize * Layout.dotDiameterMultiplier
let dotSpacing = portrait
? fontSize * Layout.dotSpacingPortraitMultiplier
: fontSize * Layout.dotSpacingLandscapeMultiplier
// Time display with consistent centering and stable layout
return Group {
if portrait {
VStack(alignment: .center) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
VStack(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)
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)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
if showSeconds {
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)
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
}
}
.frame(maxWidth: .infinity) // Center horizontally in portrait
} else {
HStack(alignment: .center) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
// Landscape mode - use fixed digit widths for stable layout
// Each digit gets the same width (based on "8") so clock doesn't shift
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)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
.frame(width: dotDiameter)
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 {
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
.frame(width: dotDiameter)
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)
.frame(maxWidth: .infinity, maxHeight: .infinity) // Center in landscape
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.overlay(alignment: .bottomTrailing) {
if shouldShowAmPm {
Text(amPmText)
@ -132,18 +175,153 @@ struct TimeDisplayView: View {
}
}
.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
.minimumScaleFactor(0.1)
.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)
.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
}
}
@ -156,14 +334,13 @@ struct TimeDisplayView: View {
showAmPm: true,
digitColor: .white,
glowIntensity: 0.2,
manualScale: 1.0,
stretched: true,
clockOpacity: 1.0,
fontFamily: .verdana,
fontWeight: .medium,
fontDesign: .default,
forceHorizontalMode: false,
isDisplayMode: false
isDisplayMode: false,
animationStyle: .spring
)
.background(Color.black)
}

View File

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

View File

@ -16,7 +16,7 @@ struct SoundCategoryView: View {
let sounds: [Sound]
@Binding var selectedSound: Sound?
@State private var selectedCategory: SoundCategory = .all
@State private var searchText: String = ""
@Binding var searchText: String
@State private var viewModel = SoundViewModel()
// MARK: - Computed Properties
@ -69,9 +69,6 @@ struct SoundCategoryView: View {
// MARK: - Body
var body: some View {
VStack(spacing: Design.Spacing.medium) {
// Search Bar
searchBar
// Category Tabs
categoryTabs
@ -83,24 +80,6 @@ struct SoundCategoryView: View {
.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 {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.small) {
@ -257,7 +236,8 @@ struct SoundCard: View {
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")
],
selectedSound: .constant(nil)
selectedSound: .constant(nil),
searchText: .constant("")
)
.padding()
}

View File

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

View File

@ -0,0 +1,25 @@
//
// 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,7 +50,6 @@ enum AppConstants {
static let digitColorHex = "#FFFFFF"
static let backgroundColorHex = "#000000"
static let glowIntensity = 0.6
static let digitScale = 1.0
static let clockOpacity = 0.5
static let overlayOpacity = 0.5
static let minFontSize = 20.0

View File

@ -31,14 +31,11 @@ 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 {
switch self {
case .system:
return 0.95
case .georgia:
return 0.90
default: return 1
}
// All fonts now use 1.0 since we have accurate glyph measurement
return 1.0
}
var fontWeights: [Font.Weight] {

View File

@ -8,10 +8,17 @@
import Foundation
import SwiftUI
import UIKit
import CoreText
/// Font sizing and typography utilities
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(
digit: String,
fontName: FontFamily,
@ -23,7 +30,7 @@ struct FontUtils {
var low: CGFloat = 1.0
var high: CGFloat = 2000.0
while high - low > 0.01 {
while high - low > 0.5 {
let mid = (low + high) / 2
let testFont = createUIFont(
name: fontName,
@ -31,7 +38,7 @@ struct FontUtils {
design: design,
size: mid
)
let textSize = tightBoundingBox(for: digit, withFont: testFont)
let textSize = glyphBoundingBox(for: digit, withFont: testFont)
if textSize.width <= size.width && textSize.height <= size.height {
low = mid
@ -40,9 +47,60 @@ struct FontUtils {
}
}
// Apply more conservative sizing in full screen mode
let baseSize = low * fontName.percentageDownsize
return isDisplayMode ? baseSize * 0.95 : baseSize
// Apply minimal safety margin - removed excessive downsize factors
let finalSize = low * safetyMargin
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(
@ -178,14 +236,13 @@ private struct TestContentView: View {
showAmPm: true,
digitColor: .primary,
glowIntensity: 0.5,
manualScale: 1.0,
stretched: false,
clockOpacity: 1.0,
fontFamily: font,
fontWeight: fontWeight,
fontDesign: fontDesign,
forceHorizontalMode: true,
isDisplayMode: false)
isDisplayMode: false,
animationStyle: .spring)
TimeSegment(
text: digit,
@ -196,7 +253,8 @@ private struct TestContentView: View {
fontFamily: font, // FontFamily
fontWeight: fontWeight,
fontDesign: fontDesign,
isDisplayMode: false
isDisplayMode: false,
animationStyle: .spring
)
}
.frame(width: 400, height: 200)
@ -262,7 +320,8 @@ private struct FontSampleCell: View {
fontFamily: font, // FontFamily
fontWeight: weight,
fontDesign: design,
isDisplayMode: false)
isDisplayMode: false,
animationStyle: .spring)
.frame(width: 100, height: 100)
.border(Color.black)
}