Compare commits
10 Commits
a88a02b671
...
301d31cb77
| Author | SHA1 | Date | |
|---|---|---|---|
| 301d31cb77 | |||
| 3ece45d215 | |||
| b630996db2 | |||
| 7338c576e8 | |||
| 443f1e02ec | |||
| 69793e85c5 | |||
| c93d80e591 | |||
| 51d6859209 | |||
| 6c2846a072 | |||
| 403eeb620f |
9
AGENTS.md
Normal file
9
AGENTS.md
Normal 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
21
PRD.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 (Auto‑Fit)",
|
||||
subtitle: "Fit the clock to available space",
|
||||
isOn: $style.stretched,
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
if !style.stretched {
|
||||
SettingsSlider(
|
||||
title: "Size",
|
||||
subtitle: "Manual scaling when auto‑fit 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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user