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

This commit is contained in:
Matt Bruce 2026-02-01 13:54:52 -06:00
parent b630996db2
commit 3ece45d215
12 changed files with 180 additions and 18 deletions

8
PRD.md
View File

@ -22,8 +22,16 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **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%)

View File

@ -28,6 +28,8 @@ TheNoiseClock is a distraction-free digital clock with built-in white noise and
- 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

@ -41,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
@ -78,6 +79,7 @@ class ClockStyle: Codable, Equatable {
case fontFamily
case fontWeight
case fontDesign
case digitAnimationStyle
case showBattery
case showDate
case dateFormat
@ -125,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
@ -157,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)
@ -449,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

@ -105,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

@ -36,7 +36,8 @@ struct ClockDisplayContainer: View {
fontWeight: style.fontWeight,
fontDesign: style.fontDesign,
forceHorizontalMode: style.forceHorizontalMode,
isDisplayMode: isDisplayMode
isDisplayMode: isDisplayMode,
animationStyle: style.digitAnimationStyle
)
.padding(.top, topSpacing)
.frame(width: geometry.size.width, height: geometry.size.height)

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

@ -22,6 +22,7 @@ struct DigitView: View {
let digitColor: Color
let glowIntensity: Double
let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
@Binding var fontSize: CGFloat
init(digit: String,
@ -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,6 +43,7 @@ struct DigitView: View {
self.digitColor = digitColor
self.glowIntensity = glowIntensity
self.isDisplayMode = isDisplayMode
self.animationStyle = animationStyle
self._fontSize = fontSize
}
@ -65,12 +68,14 @@ struct DigitView: View {
.foregroundColor(digitColor)
.blur(radius: glowRadius)
.opacity(glowOpacity)
.modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity))
}
private var mainText: some View {
baseText
.foregroundColor(digitColor)
.opacity(opacity)
.modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize))
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
}
@ -84,6 +89,85 @@ struct DigitView: View {
}
}
// 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)
}
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)
}
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 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)
}
}
}
}
// MARK: - Preview
#Preview {
@Previewable @State var sharedFontSize: CGFloat = 2000
@ -98,7 +182,8 @@ struct DigitView: View {
weight: weight,
design: design,
glowIntensity: glowIntensity,
fontSize: $sharedFontSize)
fontSize: $sharedFontSize,
animationStyle: .spring)
.border(Color.black)
DigitView(digit: "1",
@ -106,7 +191,8 @@ struct DigitView: View {
weight: weight,
design: design,
glowIntensity: glowIntensity,
fontSize: $sharedFontSize)
fontSize: $sharedFontSize,
animationStyle: .spring)
.border(Color.black)
Text(":")
@ -118,7 +204,8 @@ struct DigitView: View {
weight: weight,
design: design,
glowIntensity: glowIntensity,
fontSize: $sharedFontSize)
fontSize: $sharedFontSize,
animationStyle: .spring)
.border(Color.black)
DigitView(digit: "5",
@ -126,7 +213,8 @@ struct DigitView: View {
weight: weight,
design: design,
glowIntensity: glowIntensity,
fontSize: $sharedFontSize)
fontSize: $sharedFontSize,
animationStyle: .spring)
.border(Color.black)
}
}

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",

View File

@ -36,6 +36,7 @@ struct TimeDisplayView: View {
let fontDesign: Font.Design
let forceHorizontalMode: Bool
let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
@State var fontSize: CGFloat = 100
@State private var lastCalculatedContainerSize: CGSize = .zero
@ -119,12 +120,12 @@ struct TimeDisplayView: View {
return Group {
if portrait {
VStack(alignment: .center, spacing: 0) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
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
@ -140,16 +141,16 @@ struct TimeDisplayView: View {
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)
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)
.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 {
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)
}
}
@ -338,7 +339,8 @@ struct TimeDisplayView: View {
fontWeight: .medium,
fontDesign: .default,
forceHorizontalMode: false,
isDisplayMode: false
isDisplayMode: false,
animationStyle: .spring
)
.background(Color.black)
}

View File

@ -24,6 +24,7 @@ struct TimeSegment: View {
let fontWeight: Font.Weight
let fontDesign: Font.Design
let isDisplayMode: Bool
let animationStyle: DigitAnimationStyle
var body: some View {
// Use fixed digit width based on widest digit ("8") for stable layout
@ -45,7 +46,8 @@ struct TimeSegment: View {
opacity: clampedOpacity,
glowIntensity: glowIntensity,
fontSize: $fontSize,
isDisplayMode: isDisplayMode
isDisplayMode: isDisplayMode,
animationStyle: animationStyle
)
.frame(width: fixedDigitWidth) // Fixed width for stability
.debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)")
@ -72,7 +74,8 @@ struct TimeSegment: View {
fontFamily: .system,
fontWeight: .regular,
fontDesign: .default,
isDisplayMode: true
isDisplayMode: true,
animationStyle: .spring
)
.background(Color.black)
}

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

@ -241,7 +241,8 @@ private struct TestContentView: View {
fontWeight: fontWeight,
fontDesign: fontDesign,
forceHorizontalMode: true,
isDisplayMode: false)
isDisplayMode: false,
animationStyle: .spring)
TimeSegment(
text: digit,
@ -252,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)
@ -318,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)
}