Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b630996db2
commit
3ece45d215
8
PRD.md
8
PRD.md
@ -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%)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user