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
|
- **Individual digit views** with consistent spacing and alignment
|
||||||
- **Font customization** with family, weight, and design selection
|
- **Font customization** with family, weight, and design selection
|
||||||
- **Dynamic dot sizing** that matches selected font weight
|
- **Dynamic dot sizing** that matches selected font weight
|
||||||
- **Safe area handling** with proper Dynamic Island avoidance
|
- **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
|
- **Full-screen mode** with status bar hiding and tab bar expansion
|
||||||
- **Orientation-aware spacing** for optimal layout in all orientations
|
- **Orientation-aware spacing** for optimal layout in all orientations
|
||||||
|
- **Modern iOS 18+ Animations**:
|
||||||
|
- **Selectable Animation Styles**: Choose from effective animation styles including None, Spring, Bounce, and Glitch.
|
||||||
|
- **Numeric Text Transitions**: Smooth scrolling transitions for digits using `.contentTransition(.numericText())` (available in most styles).
|
||||||
|
- **Phase-Based Digit Animations**: Dynamic scale, vertical offset, and jitter effects when digits change.
|
||||||
|
- **Glitch Effect**: High-energy digital jitter with random offsets and rapid opacity shifts.
|
||||||
|
- **Dynamic Glow Pulsing**: Glow intensity and blur radius pulse during digit transitions for enhanced visual feedback.
|
||||||
|
- **Breathing Colon Effect**: Subtle opacity pulsing for colon separators to add life to the display.
|
||||||
|
|
||||||
### 2. Clock Customization
|
### 2. Clock Customization
|
||||||
|
- **Selectable digit animation styles**: Choose from None, Spring, Bounce, and Glitch
|
||||||
- **Color customization**: User-selectable digit colors with color picker
|
- **Color customization**: User-selectable digit colors with color picker
|
||||||
- **Background color**: Customizable background with color picker
|
- **Background color**: Customizable background with color picker
|
||||||
- **Glow effects**: Adjustable glow intensity (0-100%)
|
- **Glow effects**: Adjustable glow intensity (0-100%)
|
||||||
@ -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
|
- **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding
|
||||||
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
|
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
|
||||||
- **Smooth transitions**: Animated transitions between modes
|
- **Smooth transitions**: Animated transitions between modes
|
||||||
- **Status bar control**: Status bar 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
|
- **Safe area expansion**: Clock expands into tab bar area when hidden
|
||||||
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
- **Dynamic Island awareness**: Proper spacing to avoid Dynamic Island overlap
|
||||||
- **Orientation handling**: Full-screen mode works in both portrait and landscape
|
- **Orientation handling**: Full-screen mode works in both portrait and landscape
|
||||||
@ -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
|
- **Orientation-aware spacing**: Different spacing values for portrait vs landscape
|
||||||
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
|
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
|
||||||
- **Dot weight matching**: Colon dots scale with selected font weight
|
- **Dot weight matching**: Colon dots scale with selected font weight
|
||||||
|
- **Dot sizing balance**: Tuned dot sizing multipliers preserve readability while maximizing digit size in tight layouts
|
||||||
- **Overflow prevention**: Spacing calculations prevent content clipping
|
- **Overflow prevention**: Spacing calculations prevent content clipping
|
||||||
- **Perfect centering**: All elements centered both horizontally and vertically
|
- **Perfect centering**: All elements centered both horizontally and vertically
|
||||||
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views
|
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views
|
||||||
|
|
||||||
### Full-Screen Mode Enhancements
|
### Full-Screen Mode Enhancements
|
||||||
- **Status bar hiding**: 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
|
- **Tab bar expansion**: Clock expands into tab bar area when hidden
|
||||||
- **Safe area management**: Proper handling of Dynamic Island and other safe areas
|
- **Safe area management**: Proper handling of Dynamic Island and other safe areas
|
||||||
- **Smooth transitions**: Animated transitions between normal and full-screen modes
|
- **Smooth transitions**: Animated transitions between normal and full-screen modes
|
||||||
@ -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
|
- **Navigation destinations**: Deep linking for alarm editing
|
||||||
- **Toolbar integration**: Settings and add buttons in navigation bars
|
- **Toolbar integration**: Settings and add buttons in navigation bars
|
||||||
- **Sheet presentations**: Modal settings and alarm creation
|
- **Sheet presentations**: Modal settings and alarm creation
|
||||||
|
- **Title presentation**: Inline titles on iPhone; titles hidden on iPad tab screens
|
||||||
|
|
||||||
### Visual Design
|
### Visual Design
|
||||||
- **Rounded corners**: Modern iOS design language
|
- **Rounded corners**: Modern iOS design language
|
||||||
@ -556,13 +566,14 @@ The following changes **automatically require** PRD updates:
|
|||||||
- Snooze duration settings
|
- Snooze duration settings
|
||||||
|
|
||||||
### Noise Tab
|
### Noise Tab
|
||||||
1. **Sound Selection**: Browse sounds by category with 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
|
2. **Sound Preview**: Long-press for 3-second preview
|
||||||
3. **Visual Feedback**: Grid layout with clear selection states
|
3. **Visual Feedback**: Grid layout with clear selection states
|
||||||
4. **Auto-stop**: Automatically stops current sound when selecting new one
|
4. **Auto-stop**: Automatically stops current sound when selecting new one
|
||||||
5. **Play/Stop Controls**: Simple button with visual feedback
|
5. **Play/Stop Controls**: Simple button with visual feedback
|
||||||
6. **Continuous playback**: Sounds loop until stopped
|
6. **Continuous playback**: Sounds loop until stopped
|
||||||
7. **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
|
## 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
|
- Auto-fit sizing and manual scale control
|
||||||
- Custom fonts, weights, and designs with live preview
|
- Custom fonts, weights, and designs with live preview
|
||||||
- Glow and opacity controls for low-light comfort
|
- Glow and opacity controls for low-light comfort
|
||||||
|
- Clock tab hides the status bar for a distraction-free display
|
||||||
|
- Selectable animation styles: None, Spring, Bounce, and Glitch
|
||||||
|
- Modern iOS 18+ animations: numeric transitions, phase-based bounces, glitch effects, and breathing colons
|
||||||
|
|
||||||
**White Noise**
|
**White Noise**
|
||||||
- Multiple ambient categories and curated sound packs
|
- Multiple ambient categories and curated sound packs
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -52,14 +52,17 @@ struct ContentView: View {
|
|||||||
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
ClockSettingsView(style: clockViewModel.style) { newStyle in
|
||||||
clockViewModel.updateStyle(newStyle)
|
clockViewModel.updateStyle(newStyle)
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Settings", systemImage: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
}
|
}
|
||||||
.tag(Tab.settings)
|
.tag(Tab.settings)
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedTab) { oldValue, newValue in
|
||||||
|
if oldValue == .clock && newValue != .clock {
|
||||||
|
clockViewModel.setDisplayMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
.accentColor(AppAccent.primary)
|
.accentColor(AppAccent.primary)
|
||||||
.background(Color.Branding.primary.ignoresSafeArea())
|
.background(Color.Branding.primary.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct AlarmView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
Group {
|
Group {
|
||||||
if viewModel.alarms.isEmpty {
|
if viewModel.alarms.isEmpty {
|
||||||
EmptyAlarmsView {
|
EmptyAlarmsView {
|
||||||
@ -50,7 +51,8 @@ struct AlarmView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Alarms")
|
.navigationTitle(isPad ? "" : "Alarms")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@ -23,8 +23,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
var digitColorHex: String = AppConstants.Defaults.digitColorHex
|
var digitColorHex: String = AppConstants.Defaults.digitColorHex
|
||||||
var randomizeColor: Bool = false
|
var randomizeColor: Bool = false
|
||||||
var glowIntensity: Double = AppConstants.Defaults.glowIntensity
|
var glowIntensity: Double = AppConstants.Defaults.glowIntensity
|
||||||
var digitScale: Double = AppConstants.Defaults.digitScale
|
|
||||||
var stretched: Bool = true
|
|
||||||
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
|
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
|
||||||
|
|
||||||
// MARK: - Color Theme Settings
|
// MARK: - Color Theme Settings
|
||||||
@ -43,6 +41,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
var fontFamily: FontFamily = .system
|
var fontFamily: FontFamily = .system
|
||||||
var fontWeight: Font.Weight = .bold
|
var fontWeight: Font.Weight = .bold
|
||||||
var fontDesign: Font.Design = .rounded
|
var fontDesign: Font.Design = .rounded
|
||||||
|
var digitAnimationStyle: DigitAnimationStyle = .spring
|
||||||
|
|
||||||
// MARK: - Overlay Settings
|
// MARK: - Overlay Settings
|
||||||
var showBattery: Bool = true
|
var showBattery: Bool = true
|
||||||
@ -68,8 +67,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case digitColorHex
|
case digitColorHex
|
||||||
case randomizeColor
|
case randomizeColor
|
||||||
case glowIntensity
|
case glowIntensity
|
||||||
case digitScale
|
|
||||||
case stretched
|
|
||||||
case backgroundHex
|
case backgroundHex
|
||||||
case selectedColorTheme
|
case selectedColorTheme
|
||||||
case nightModeEnabled
|
case nightModeEnabled
|
||||||
@ -82,6 +79,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
case fontFamily
|
case fontFamily
|
||||||
case fontWeight
|
case fontWeight
|
||||||
case fontDesign
|
case fontDesign
|
||||||
|
case digitAnimationStyle
|
||||||
case showBattery
|
case showBattery
|
||||||
case showDate
|
case showDate
|
||||||
case dateFormat
|
case dateFormat
|
||||||
@ -107,8 +105,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
|
self.digitColorHex = try container.decodeIfPresent(String.self, forKey: .digitColorHex) ?? self.digitColorHex
|
||||||
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
|
self.randomizeColor = try container.decodeIfPresent(Bool.self, forKey: .randomizeColor) ?? self.randomizeColor
|
||||||
self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity
|
self.glowIntensity = try container.decodeIfPresent(Double.self, forKey: .glowIntensity) ?? self.glowIntensity
|
||||||
self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale
|
|
||||||
self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched
|
|
||||||
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
|
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
|
||||||
self.selectedColorTheme = try container.decodeIfPresent(String.self, forKey: .selectedColorTheme) ?? self.selectedColorTheme
|
self.selectedColorTheme = try container.decodeIfPresent(String.self, forKey: .selectedColorTheme) ?? self.selectedColorTheme
|
||||||
self.nightModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .nightModeEnabled) ?? self.nightModeEnabled
|
self.nightModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .nightModeEnabled) ?? self.nightModeEnabled
|
||||||
@ -131,6 +127,10 @@ class ClockStyle: Codable, Equatable {
|
|||||||
let decoded = Font.Design(rawValue: fontDesignString) {
|
let decoded = Font.Design(rawValue: fontDesignString) {
|
||||||
self.fontDesign = decoded
|
self.fontDesign = decoded
|
||||||
}
|
}
|
||||||
|
if let animationStyleRaw = try container.decodeIfPresent(String.self, forKey: .digitAnimationStyle),
|
||||||
|
let decoded = DigitAnimationStyle(rawValue: animationStyleRaw) {
|
||||||
|
self.digitAnimationStyle = decoded
|
||||||
|
}
|
||||||
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
|
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
|
||||||
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
|
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
|
||||||
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
self.dateFormat = try container.decodeIfPresent(String.self, forKey: .dateFormat) ?? self.dateFormat
|
||||||
@ -151,8 +151,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(digitColorHex, forKey: .digitColorHex)
|
try container.encode(digitColorHex, forKey: .digitColorHex)
|
||||||
try container.encode(randomizeColor, forKey: .randomizeColor)
|
try container.encode(randomizeColor, forKey: .randomizeColor)
|
||||||
try container.encode(glowIntensity, forKey: .glowIntensity)
|
try container.encode(glowIntensity, forKey: .glowIntensity)
|
||||||
try container.encode(digitScale, forKey: .digitScale)
|
|
||||||
try container.encode(stretched, forKey: .stretched)
|
|
||||||
try container.encode(backgroundHex, forKey: .backgroundHex)
|
try container.encode(backgroundHex, forKey: .backgroundHex)
|
||||||
try container.encode(selectedColorTheme, forKey: .selectedColorTheme)
|
try container.encode(selectedColorTheme, forKey: .selectedColorTheme)
|
||||||
try container.encode(nightModeEnabled, forKey: .nightModeEnabled)
|
try container.encode(nightModeEnabled, forKey: .nightModeEnabled)
|
||||||
@ -165,6 +163,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
try container.encode(fontFamily.rawValue, forKey: .fontFamily)
|
try container.encode(fontFamily.rawValue, forKey: .fontFamily)
|
||||||
try container.encode(fontWeight.rawValue, forKey: .fontWeight)
|
try container.encode(fontWeight.rawValue, forKey: .fontWeight)
|
||||||
try container.encode(fontDesign.rawValue, forKey: .fontDesign)
|
try container.encode(fontDesign.rawValue, forKey: .fontDesign)
|
||||||
|
try container.encode(digitAnimationStyle.rawValue, forKey: .digitAnimationStyle)
|
||||||
try container.encode(showBattery, forKey: .showBattery)
|
try container.encode(showBattery, forKey: .showBattery)
|
||||||
try container.encode(showDate, forKey: .showDate)
|
try container.encode(showDate, forKey: .showDate)
|
||||||
try container.encode(dateFormat, forKey: .dateFormat)
|
try container.encode(dateFormat, forKey: .dateFormat)
|
||||||
@ -445,8 +444,6 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.digitColorHex == rhs.digitColorHex &&
|
lhs.digitColorHex == rhs.digitColorHex &&
|
||||||
lhs.randomizeColor == rhs.randomizeColor &&
|
lhs.randomizeColor == rhs.randomizeColor &&
|
||||||
lhs.glowIntensity == rhs.glowIntensity &&
|
lhs.glowIntensity == rhs.glowIntensity &&
|
||||||
lhs.digitScale == rhs.digitScale &&
|
|
||||||
lhs.stretched == rhs.stretched &&
|
|
||||||
lhs.backgroundHex == rhs.backgroundHex &&
|
lhs.backgroundHex == rhs.backgroundHex &&
|
||||||
lhs.selectedColorTheme == rhs.selectedColorTheme &&
|
lhs.selectedColorTheme == rhs.selectedColorTheme &&
|
||||||
lhs.nightModeEnabled == rhs.nightModeEnabled &&
|
lhs.nightModeEnabled == rhs.nightModeEnabled &&
|
||||||
@ -459,6 +456,7 @@ class ClockStyle: Codable, Equatable {
|
|||||||
lhs.fontFamily == rhs.fontFamily &&
|
lhs.fontFamily == rhs.fontFamily &&
|
||||||
lhs.fontWeight == rhs.fontWeight &&
|
lhs.fontWeight == rhs.fontWeight &&
|
||||||
lhs.fontDesign == rhs.fontDesign &&
|
lhs.fontDesign == rhs.fontDesign &&
|
||||||
|
lhs.digitAnimationStyle == rhs.digitAnimationStyle &&
|
||||||
lhs.showBattery == rhs.showBattery &&
|
lhs.showBattery == rhs.showBattery &&
|
||||||
lhs.showDate == rhs.showDate &&
|
lhs.showDate == rhs.showDate &&
|
||||||
lhs.dateFormat == rhs.dateFormat &&
|
lhs.dateFormat == rhs.dateFormat &&
|
||||||
|
|||||||
@ -69,6 +69,14 @@ class ClockViewModel {
|
|||||||
updateWakeLockState()
|
updateWakeLockState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDisplayMode(_ enabled: Bool) {
|
||||||
|
guard isDisplayMode != enabled else { return }
|
||||||
|
withAnimation(Design.Animation.spring(bounce: Design.Animation.springBounce)) {
|
||||||
|
isDisplayMode = enabled
|
||||||
|
}
|
||||||
|
updateWakeLockState()
|
||||||
|
}
|
||||||
|
|
||||||
func updateStyle(_ newStyle: ClockStyle) {
|
func updateStyle(_ newStyle: ClockStyle) {
|
||||||
|
|
||||||
// Update properties of the existing style object instead of replacing it
|
// Update properties of the existing style object instead of replacing it
|
||||||
@ -79,8 +87,6 @@ class ClockViewModel {
|
|||||||
style.forceHorizontalMode = newStyle.forceHorizontalMode
|
style.forceHorizontalMode = newStyle.forceHorizontalMode
|
||||||
style.digitColorHex = newStyle.digitColorHex
|
style.digitColorHex = newStyle.digitColorHex
|
||||||
style.glowIntensity = newStyle.glowIntensity
|
style.glowIntensity = newStyle.glowIntensity
|
||||||
style.digitScale = newStyle.digitScale
|
|
||||||
style.stretched = newStyle.stretched
|
|
||||||
style.clockOpacity = newStyle.clockOpacity
|
style.clockOpacity = newStyle.clockOpacity
|
||||||
style.fontFamily = newStyle.fontFamily
|
style.fontFamily = newStyle.fontFamily
|
||||||
style.fontWeight = newStyle.fontWeight
|
style.fontWeight = newStyle.fontWeight
|
||||||
@ -99,6 +105,7 @@ class ClockViewModel {
|
|||||||
style.nightModeEndTime = newStyle.nightModeEndTime
|
style.nightModeEndTime = newStyle.nightModeEndTime
|
||||||
style.ambientLightThreshold = newStyle.ambientLightThreshold
|
style.ambientLightThreshold = newStyle.ambientLightThreshold
|
||||||
style.autoBrightness = newStyle.autoBrightness
|
style.autoBrightness = newStyle.autoBrightness
|
||||||
|
style.digitAnimationStyle = newStyle.digitAnimationStyle
|
||||||
style.dateFormat = newStyle.dateFormat
|
style.dateFormat = newStyle.dateFormat
|
||||||
style.respectFocusModes = newStyle.respectFocusModes
|
style.respectFocusModes = newStyle.respectFocusModes
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ struct ClockSettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Design.Spacing.xxLarge) {
|
VStack(spacing: Design.Spacing.xxLarge) {
|
||||||
BasicAppearanceSection(
|
BasicAppearanceSection(
|
||||||
@ -101,7 +102,7 @@ struct ClockSettingsView: View {
|
|||||||
.padding(.bottom, Design.Spacing.xxxLarge)
|
.padding(.bottom, Design.Spacing.xxxLarge)
|
||||||
}
|
}
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.navigationTitle("Clock Settings")
|
.navigationTitle(isPad ? "" : "Settings")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
digitColor = Color(hex: style.digitColorHex) ?? .white
|
digitColor = Color(hex: style.digitColorHex) ?? .white
|
||||||
|
|||||||
@ -11,45 +11,73 @@ import Bedrock
|
|||||||
/// Main clock display view with settings and display mode
|
/// Main clock display view with settings and display mode
|
||||||
struct ClockView: View {
|
struct ClockView: View {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
private static let debugShowSafeAreas = false
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
@Bindable var viewModel: ClockViewModel
|
@Bindable var viewModel: ClockViewModel
|
||||||
@State private var showFullScreenHint = false
|
|
||||||
@State private var idleTimer: Timer?
|
@State private var idleTimer: Timer?
|
||||||
@State private var didHandleTouch = false
|
@State private var didHandleTouch = false
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
viewModel.style.effectiveBackgroundColor
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
|
// When ignoring safe areas, geometry.size IS the full screen
|
||||||
|
let screenWidth = geometry.size.width
|
||||||
|
let screenHeight = geometry.size.height
|
||||||
|
let isLandscape = screenWidth > screenHeight
|
||||||
|
|
||||||
|
// Get safe area insets from UIWindow since GeometryReader ignores them
|
||||||
|
let windowInsets = Self.getWindowSafeAreaInsets()
|
||||||
|
let safeInsets = geometry.safeAreaInsets // May be 0 due to ignoresSafeArea
|
||||||
|
|
||||||
|
// Dynamic Island handling:
|
||||||
|
// In landscape, apply symmetric padding to keep content centered
|
||||||
|
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
|
||||||
|
let symmetricInset = isLandscape ? dynamicIslandInset : 0
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
// Main clock display container
|
// Background extends to full screen
|
||||||
|
viewModel.style.effectiveBackgroundColor
|
||||||
|
|
||||||
|
// Main clock display container with symmetric padding for Dynamic Island
|
||||||
ClockDisplayContainer(
|
ClockDisplayContainer(
|
||||||
currentTime: viewModel.currentTime,
|
currentTime: viewModel.currentTime,
|
||||||
style: viewModel.style,
|
style: viewModel.style,
|
||||||
isDisplayMode: viewModel.isDisplayMode
|
isDisplayMode: viewModel.isDisplayMode
|
||||||
)
|
)
|
||||||
|
.padding(.leading, symmetricInset)
|
||||||
|
.padding(.trailing, symmetricInset)
|
||||||
|
.debugBorder(Self.debugShowSafeAreas, color: .yellow, label: "ClockDisplayContainer")
|
||||||
|
|
||||||
// Top overlay container
|
// Top overlay container with symmetric padding
|
||||||
ClockOverlayContainer(style: viewModel.style)
|
ClockOverlayContainer(style: viewModel.style)
|
||||||
|
.padding(.leading, symmetricInset)
|
||||||
// Full screen hint overlay
|
.padding(.trailing, symmetricInset)
|
||||||
if showFullScreenHint {
|
|
||||||
FullScreenHintView(isDisplayMode: viewModel.isDisplayMode)
|
|
||||||
}
|
}
|
||||||
}
|
.frame(width: screenWidth, height: screenHeight)
|
||||||
}
|
.overlay(alignment: .bottomLeading) {
|
||||||
}
|
if Self.debugShowSafeAreas {
|
||||||
.ignoresSafeArea(.all, edges: viewModel.isDisplayMode ? .bottom : [])
|
safeAreaDebugInfo(
|
||||||
.statusBarHidden(viewModel.isDisplayMode)
|
size: geometry.size,
|
||||||
.overlay {
|
windowInsets: windowInsets,
|
||||||
// Toolbar overlay
|
symmetricInset: symmetricInset
|
||||||
ClockToolbar(
|
|
||||||
isDisplayMode: viewModel.isDisplayMode
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
|
||||||
|
}
|
||||||
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
|
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
|
||||||
|
}
|
||||||
|
.onChange(of: safeInsets) { _, newInsets in
|
||||||
|
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.statusBarHidden(true)
|
||||||
.overlay {
|
.overlay {
|
||||||
// Tab bar management overlay
|
// Tab bar management overlay
|
||||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
||||||
@ -95,18 +123,59 @@ struct ClockView: View {
|
|||||||
private func enterDisplayModeFromIdle() {
|
private func enterDisplayModeFromIdle() {
|
||||||
guard !viewModel.isDisplayMode else { return }
|
guard !viewModel.isDisplayMode else { return }
|
||||||
viewModel.toggleDisplayMode()
|
viewModel.toggleDisplayMode()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
showFullScreenHint = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleUserInteraction() {
|
private func handleUserInteraction() {
|
||||||
if viewModel.isDisplayMode {
|
if viewModel.isDisplayMode {
|
||||||
viewModel.toggleDisplayMode()
|
viewModel.toggleDisplayMode()
|
||||||
showFullScreenHint = false
|
|
||||||
}
|
}
|
||||||
resetIdleTimer()
|
resetIdleTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Safe Area Helpers
|
||||||
|
/// Get safe area insets from the key window (works even when ignoring safe areas)
|
||||||
|
private static func getWindowSafeAreaInsets() -> UIEdgeInsets {
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = scene.windows.first else {
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
return window.safeAreaInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Views
|
||||||
|
@ViewBuilder
|
||||||
|
private func safeAreaDebugInfo(
|
||||||
|
size: CGSize,
|
||||||
|
windowInsets: UIEdgeInsets,
|
||||||
|
symmetricInset: CGFloat
|
||||||
|
) -> some View {
|
||||||
|
let isLandscape = size.width > size.height
|
||||||
|
let hasDynamicIsland = windowInsets.left > 0 || windowInsets.right > 0
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Screen: \(Int(size.width))×\(Int(size.height))")
|
||||||
|
Text("Window Insets: L:\(Int(windowInsets.left)) R:\(Int(windowInsets.right)) T:\(Int(windowInsets.top)) B:\(Int(windowInsets.bottom))")
|
||||||
|
Text("Symmetric Inset: \(Int(symmetricInset))")
|
||||||
|
Text("Dynamic Island: \(hasDynamicIsland && isLandscape ? "Yes" : "No")")
|
||||||
|
Text("Orientation: \(isLandscape ? "Landscape" : "Portrait")")
|
||||||
|
}
|
||||||
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.black.opacity(0.7))
|
||||||
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Logging
|
||||||
|
private func logClockLayout(size: CGSize, safeAreaInsets: EdgeInsets) {
|
||||||
|
let isLandscape = size.width > size.height
|
||||||
|
let safeInset = max(safeAreaInsets.leading, safeAreaInsets.trailing)
|
||||||
|
let symmetricInset = isLandscape ? safeInset : 0
|
||||||
|
Design.debugLog("[clockLayout] size=\(String(format: "%.1f", size.width))x\(String(format: "%.1f", size.height))")
|
||||||
|
Design.debugLog("[clockLayout] insets=(t:\(String(format: "%.1f", safeAreaInsets.top)), l:\(String(format: "%.1f", safeAreaInsets.leading)), b:\(String(format: "%.1f", safeAreaInsets.bottom)), r:\(String(format: "%.1f", safeAreaInsets.trailing)))")
|
||||||
|
Design.debugLog("[clockLayout] isLandscape=\(isLandscape), safeInset=\(String(format: "%.1f", safeInset)), symmetricInset=\(String(format: "%.1f", symmetricInset))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@ -18,19 +18,12 @@ struct ClockDisplayContainer: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
return GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let isPortrait = geometry.size.height >= geometry.size.width
|
let isPortrait = geometry.size.height >= geometry.size.width
|
||||||
let hasOverlay = style.showBattery || style.showDate
|
let hasOverlay = style.showBattery || style.showDate
|
||||||
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
// Time display - fills all available space
|
||||||
// Top spacing to account for overlay
|
|
||||||
if hasOverlay {
|
|
||||||
Spacer()
|
|
||||||
.frame(height: topSpacing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time display - fills remaining space
|
|
||||||
TimeDisplayView(
|
TimeDisplayView(
|
||||||
date: currentTime,
|
date: currentTime,
|
||||||
use24Hour: style.use24Hour,
|
use24Hour: style.use24Hour,
|
||||||
@ -38,19 +31,17 @@ struct ClockDisplayContainer: View {
|
|||||||
showAmPm: style.showAmPm,
|
showAmPm: style.showAmPm,
|
||||||
digitColor: style.effectiveDigitColor,
|
digitColor: style.effectiveDigitColor,
|
||||||
glowIntensity: style.glowIntensity,
|
glowIntensity: style.glowIntensity,
|
||||||
manualScale: style.digitScale,
|
|
||||||
stretched: style.stretched,
|
|
||||||
clockOpacity: style.clockOpacity,
|
clockOpacity: style.clockOpacity,
|
||||||
fontFamily: style.fontFamily,
|
fontFamily: style.fontFamily,
|
||||||
fontWeight: style.fontWeight,
|
fontWeight: style.fontWeight,
|
||||||
fontDesign: style.fontDesign,
|
fontDesign: style.fontDesign,
|
||||||
forceHorizontalMode: style.forceHorizontalMode,
|
forceHorizontalMode: style.forceHorizontalMode,
|
||||||
isDisplayMode: isDisplayMode
|
isDisplayMode: isDisplayMode,
|
||||||
|
animationStyle: style.digitAnimationStyle
|
||||||
)
|
)
|
||||||
|
.padding(.top, topSpacing)
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
|
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,10 @@ struct ClockToolbar: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.navigationTitle(isDisplayMode ? "" : "Clock")
|
.navigationTitle(isDisplayMode || isPad ? "" : "Clock")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(isDisplayMode)
|
.navigationBarBackButtonHidden(isDisplayMode)
|
||||||
.toolbar(isDisplayMode ? .hidden : .automatic)
|
.toolbar(isDisplayMode ? .hidden : .automatic)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,12 @@ struct ColonView: View {
|
|||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
|
.phaseAnimator([0, 1]) { content, phase in
|
||||||
|
content
|
||||||
|
.opacity(phase == 1 ? 1.0 : 0.6)
|
||||||
|
} animation: { _ in
|
||||||
|
.easeInOut(duration: 1.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Component for displaying a single digit with fixed width and glow effects
|
/// Component for displaying a single digit with fixed width and glow effects
|
||||||
struct DigitView: View {
|
struct DigitView: View {
|
||||||
@Environment(\.sizeCategory) private var sizeCategory
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
private static let debugShowBorders = false
|
||||||
|
|
||||||
let digit: String
|
let digit: String
|
||||||
let fontName: FontFamily
|
let fontName: FontFamily
|
||||||
@ -19,11 +22,9 @@ struct DigitView: View {
|
|||||||
let digitColor: Color
|
let digitColor: Color
|
||||||
let glowIntensity: Double
|
let glowIntensity: Double
|
||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
|
let animationStyle: DigitAnimationStyle
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
|
|
||||||
@State private var lastCalculatedSize: CGSize = .zero
|
|
||||||
@State private var isCalculating: Bool = false
|
|
||||||
|
|
||||||
init(digit: String,
|
init(digit: String,
|
||||||
fontName: FontFamily,
|
fontName: FontFamily,
|
||||||
weight: Font.Weight = .regular,
|
weight: Font.Weight = .regular,
|
||||||
@ -32,7 +33,8 @@ struct DigitView: View {
|
|||||||
opacity: Double = 1,
|
opacity: Double = 1,
|
||||||
glowIntensity: Double = 0,
|
glowIntensity: Double = 0,
|
||||||
fontSize: Binding<CGFloat>,
|
fontSize: Binding<CGFloat>,
|
||||||
isDisplayMode: Bool = false) {
|
isDisplayMode: Bool = false,
|
||||||
|
animationStyle: DigitAnimationStyle = .spring) {
|
||||||
self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0"
|
self.digit = (digit.count == 1 && "0123456789".contains(digit)) ? digit : "0"
|
||||||
self.fontName = fontName
|
self.fontName = fontName
|
||||||
self.weight = weight
|
self.weight = weight
|
||||||
@ -41,18 +43,16 @@ struct DigitView: View {
|
|||||||
self.digitColor = digitColor
|
self.digitColor = digitColor
|
||||||
self.glowIntensity = glowIntensity
|
self.glowIntensity = glowIntensity
|
||||||
self.isDisplayMode = isDisplayMode
|
self.isDisplayMode = isDisplayMode
|
||||||
|
self.animationStyle = animationStyle
|
||||||
self._fontSize = fontSize
|
self._fontSize = fontSize
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack {
|
ZStack {
|
||||||
glowText
|
glowText
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
|
||||||
mainText
|
mainText
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var glowRadius: CGFloat {
|
private var glowRadius: CGFloat {
|
||||||
@ -64,75 +64,106 @@ struct DigitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var glowText: some View {
|
private var glowText: some View {
|
||||||
text
|
baseText
|
||||||
.foregroundColor(digitColor)
|
.foregroundColor(digitColor)
|
||||||
.blur(radius: glowRadius)
|
.blur(radius: glowRadius)
|
||||||
.opacity(glowOpacity)
|
.opacity(glowOpacity)
|
||||||
|
.modifier(GlowAnimationModifier(style: animationStyle, digit: digit, glowRadius: glowRadius, glowOpacity: glowOpacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainText: some View {
|
private var mainText: some View {
|
||||||
text
|
baseText
|
||||||
.foregroundColor(digitColor)
|
.foregroundColor(digitColor)
|
||||||
.opacity(opacity)
|
.opacity(opacity)
|
||||||
|
.modifier(DigitAnimationModifier(style: animationStyle, digit: digit, fontSize: fontSize))
|
||||||
|
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var text: some View {
|
private var baseText: some View {
|
||||||
GeometryReader { geometry in
|
|
||||||
Text(digit)
|
Text(digit)
|
||||||
.font(FontUtils.createFont(name: fontName,
|
.font(FontUtils.createFont(name: fontName,
|
||||||
weight: weight,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
size: fontSize))
|
size: fontSize))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.lineLimit(1)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.minimumScaleFactor(0.1)
|
|
||||||
.lineSpacing(0)
|
|
||||||
.padding(.vertical, 0)
|
|
||||||
.padding(.horizontal, 0)
|
|
||||||
.baselineOffset(0)
|
|
||||||
.onAppear {
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
.onChange(of: geometry.size) { _, newSize in
|
|
||||||
calculateOptimalFontSize(for: newSize)
|
|
||||||
}
|
|
||||||
.onChange(of: sizeCategory) { _, _ in
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func calculateOptimalFontSize(for size: CGSize) {
|
// MARK: - Animation Modifiers
|
||||||
// Prevent multiple calculations for the same size
|
|
||||||
guard size != lastCalculatedSize && !isCalculating else { return }
|
|
||||||
|
|
||||||
// Prevent multiple updates per frame
|
private struct DigitAnimationModifier: ViewModifier {
|
||||||
guard !isCalculating else { return }
|
let style: DigitAnimationStyle
|
||||||
isCalculating = true
|
let digit: String
|
||||||
|
let fontSize: CGFloat
|
||||||
|
|
||||||
let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit,
|
func body(content: Content) -> some View {
|
||||||
fontName: fontName,
|
switch style {
|
||||||
weight: weight,
|
case .none:
|
||||||
design: design,
|
content
|
||||||
for: size,
|
case .spring:
|
||||||
isDisplayMode: isDisplayMode)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only update if the size is significantly different to prevent micro-adjustments
|
private struct GlowAnimationModifier: ViewModifier {
|
||||||
fontSize = optimalSize
|
let style: DigitAnimationStyle
|
||||||
lastCalculatedSize = size
|
let digit: String
|
||||||
|
let glowRadius: CGFloat
|
||||||
|
let glowOpacity: Double
|
||||||
|
|
||||||
// Reset calculation flag after a brief delay to allow for frame completion
|
func body(content: Content) -> some View {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { // ~60fps
|
switch style {
|
||||||
isCalculating = false
|
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,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $sharedFontSize)
|
fontSize: $sharedFontSize,
|
||||||
|
animationStyle: .spring)
|
||||||
.border(Color.black)
|
.border(Color.black)
|
||||||
|
|
||||||
DigitView(digit: "1",
|
DigitView(digit: "1",
|
||||||
@ -159,7 +191,8 @@ struct DigitView: View {
|
|||||||
weight: weight,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $sharedFontSize)
|
fontSize: $sharedFontSize,
|
||||||
|
animationStyle: .spring)
|
||||||
.border(Color.black)
|
.border(Color.black)
|
||||||
|
|
||||||
Text(":")
|
Text(":")
|
||||||
@ -171,7 +204,8 @@ struct DigitView: View {
|
|||||||
weight: weight,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $sharedFontSize)
|
fontSize: $sharedFontSize,
|
||||||
|
animationStyle: .spring)
|
||||||
.border(Color.black)
|
.border(Color.black)
|
||||||
|
|
||||||
DigitView(digit: "5",
|
DigitView(digit: "5",
|
||||||
@ -179,7 +213,8 @@ struct DigitView: View {
|
|||||||
weight: weight,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $sharedFontSize)
|
fontSize: $sharedFontSize,
|
||||||
|
animationStyle: .spring)
|
||||||
.border(Color.black)
|
.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) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text("Digit Animation")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
|
||||||
|
Picker("Digit Animation", selection: $style.digitAnimationStyle) {
|
||||||
|
ForEach(DigitAnimationStyle.allCases, id: \.self) { animation in
|
||||||
|
Text(animation.displayName).tag(animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.tint(AppAccent.primary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: "Randomize Color",
|
title: "Randomize Color",
|
||||||
subtitle: "Shift the color every minute",
|
subtitle: "Shift the color every minute",
|
||||||
@ -27,25 +42,6 @@ struct AdvancedAppearanceSection: View {
|
|||||||
accentColor: AppAccent.primary
|
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(
|
SettingsSlider(
|
||||||
title: "Glow",
|
title: "Glow",
|
||||||
subtitle: "Adjust the glow intensity",
|
subtitle: "Adjust the glow intensity",
|
||||||
|
|||||||
@ -11,6 +11,18 @@ import Bedrock
|
|||||||
/// Component for displaying segmented time with customizable formatting
|
/// Component for displaying segmented time with customizable formatting
|
||||||
struct TimeDisplayView: View {
|
struct TimeDisplayView: View {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
/// When true, shows static "88:88:88" to debug font sizing
|
||||||
|
private static let debugStaticTime = false
|
||||||
|
/// When true, shows debug overlays with sizing info
|
||||||
|
private static let debugShowOverlays = false
|
||||||
|
/// Static hour value for debug mode
|
||||||
|
private static let debugHour = "88"
|
||||||
|
/// Static minute value for debug mode
|
||||||
|
private static let debugMinute = "88"
|
||||||
|
/// Static seconds value for debug mode
|
||||||
|
private static let debugSeconds = "88"
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let date: Date
|
let date: Date
|
||||||
let use24Hour: Bool
|
let use24Hour: Bool
|
||||||
@ -18,15 +30,22 @@ struct TimeDisplayView: View {
|
|||||||
let showAmPm: Bool
|
let showAmPm: Bool
|
||||||
let digitColor: Color
|
let digitColor: Color
|
||||||
let glowIntensity: Double
|
let glowIntensity: Double
|
||||||
let manualScale: Double
|
|
||||||
let stretched: Bool
|
|
||||||
let clockOpacity: Double
|
let clockOpacity: Double
|
||||||
let fontFamily: FontFamily
|
let fontFamily: FontFamily
|
||||||
let fontWeight: Font.Weight
|
let fontWeight: Font.Weight
|
||||||
let fontDesign: Font.Design
|
let fontDesign: Font.Design
|
||||||
let forceHorizontalMode: Bool
|
let forceHorizontalMode: Bool
|
||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
|
let animationStyle: DigitAnimationStyle
|
||||||
@State var fontSize: CGFloat = 100
|
@State var fontSize: CGFloat = 100
|
||||||
|
@State private var lastCalculatedContainerSize: CGSize = .zero
|
||||||
|
|
||||||
|
// MARK: - Layout Constants
|
||||||
|
private enum Layout {
|
||||||
|
static let dotDiameterMultiplier: CGFloat = 0.65
|
||||||
|
static let dotSpacingPortraitMultiplier: CGFloat = 0.16
|
||||||
|
static let dotSpacingLandscapeMultiplier: CGFloat = 0.22
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Formatters
|
// MARK: - Formatters
|
||||||
private static let hour24DF: DateFormatter = {
|
private static let hour24DF: DateFormatter = {
|
||||||
@ -71,49 +90,73 @@ struct TimeDisplayView: View {
|
|||||||
let portraitMode = containerSize.height >= containerSize.width
|
let portraitMode = containerSize.height >= containerSize.width
|
||||||
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
|
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
|
||||||
|
|
||||||
// Time components
|
// Time components - use debug static values if enabled
|
||||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
let hour: String
|
||||||
let minute = Self.minuteDF.string(from: date)
|
let minute: String
|
||||||
let secondsText = Self.secondDF.string(from: date)
|
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
|
// AM/PM badge - hide in debug mode
|
||||||
let shouldShowAmPm = showAmPm && !use24Hour
|
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
|
||||||
let amPmText = Self.amPmDF.string(from: date).uppercased()
|
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
|
||||||
|
|
||||||
// Separators - reasonable spacing with extra padding in landscape
|
// Separators - reasonable spacing with extra padding in landscape
|
||||||
let dotDiameter = fontSize * 0.75
|
let dotDiameter = fontSize * Layout.dotDiameterMultiplier
|
||||||
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
|
let dotSpacing = portrait
|
||||||
|
? fontSize * Layout.dotSpacingPortraitMultiplier
|
||||||
// Simple scaling - let the content size naturally and apply manual scale
|
: fontSize * Layout.dotSpacingLandscapeMultiplier
|
||||||
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
|
|
||||||
|
|
||||||
// Time display with consistent centering and stable layout
|
// Time display with consistent centering and stable layout
|
||||||
return Group {
|
return Group {
|
||||||
if portrait {
|
if portrait {
|
||||||
VStack(alignment: .center) {
|
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)
|
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 {
|
if showSeconds {
|
||||||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
||||||
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
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 {
|
} else {
|
||||||
HStack(alignment: .center) {
|
// Landscape mode - use fixed digit widths for stable layout
|
||||||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
// 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)
|
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 {
|
if showSeconds {
|
||||||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
|
||||||
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) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if shouldShowAmPm {
|
if shouldShowAmPm {
|
||||||
Text(amPmText)
|
Text(amPmText)
|
||||||
@ -132,18 +175,153 @@ struct TimeDisplayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode
|
.offset(y: portraitMode && forceHorizontalMode ? -containerSize.height * 0.10 : 0) // Push up in horizontal mode
|
||||||
.scaleEffect(finalScale, anchor: .center)
|
|
||||||
.animation(.smooth(duration: Design.Animation.standard), value: finalScale)
|
|
||||||
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
|
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
|
||||||
.minimumScaleFactor(0.1)
|
.minimumScaleFactor(0.1)
|
||||||
.clipped() // Prevent overflow beyond bounds
|
.clipped() // Prevent overflow beyond bounds
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if Self.debugShowOverlays {
|
||||||
|
debugInfoOverlay(containerSize: containerSize, portrait: portrait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.debugBorder(Self.debugShowOverlays, color: .cyan, label: "TimeDisplayView")
|
||||||
|
.onAppear {
|
||||||
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
|
.onChange(of: containerSize) { _, newSize in
|
||||||
|
updateFontSize(containerSize: newSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
|
.onChange(of: showSeconds) { _, _ in
|
||||||
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
|
.onChange(of: fontFamily) { _, _ in
|
||||||
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
|
.onChange(of: fontWeight) { _, _ in
|
||||||
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
|
.onChange(of: fontDesign) { _, _ in
|
||||||
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//.border(.yellow, width: 1)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onOrientationChange() // Force updates on orientation changes
|
.onOrientationChange() // Force updates on orientation changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Overlay
|
||||||
|
@ViewBuilder
|
||||||
|
private func debugInfoOverlay(containerSize: CGSize, portrait: Bool) -> some View {
|
||||||
|
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
|
||||||
|
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
|
||||||
|
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
|
||||||
|
let colonSize = fontSize * Layout.dotDiameterMultiplier
|
||||||
|
|
||||||
|
let availableWidth = portrait
|
||||||
|
? containerSize.width
|
||||||
|
: max(1, containerSize.width - (colonCount * colonSize))
|
||||||
|
let availableHeight = portrait
|
||||||
|
? max(1, containerSize.height - (colonCount * colonSize))
|
||||||
|
: containerSize.height
|
||||||
|
let digitSlotSize = CGSize(
|
||||||
|
width: max(1, availableWidth / digitColumns),
|
||||||
|
height: max(1, availableHeight / digitRows)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Container: \(Int(containerSize.width))×\(Int(containerSize.height))")
|
||||||
|
Text("Digit Slot: \(Int(digitSlotSize.width))×\(Int(digitSlotSize.height))")
|
||||||
|
Text("Font Size: \(Int(fontSize))")
|
||||||
|
Text("Cols: \(Int(digitColumns)) Rows: \(Int(digitRows))")
|
||||||
|
Text("Portrait: \(portrait)")
|
||||||
|
Text("Family: \(fontFamily.rawValue)")
|
||||||
|
}
|
||||||
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.black.opacity(0.7))
|
||||||
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Font Sizing
|
||||||
|
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
|
||||||
|
guard containerSize != .zero else { return }
|
||||||
|
|
||||||
|
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
|
||||||
|
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
|
||||||
|
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
|
||||||
|
let previousFontSize = fontSize
|
||||||
|
|
||||||
|
func calculateFontSize(reservingColonSize colonSize: CGFloat) -> CGFloat {
|
||||||
|
let availableWidth = portrait
|
||||||
|
? containerSize.width
|
||||||
|
: max(1, containerSize.width - (colonCount * colonSize))
|
||||||
|
let availableHeight = portrait
|
||||||
|
? max(1, containerSize.height - (colonCount * colonSize))
|
||||||
|
: containerSize.height
|
||||||
|
let digitSize = CGSize(
|
||||||
|
width: max(1, availableWidth / digitColumns),
|
||||||
|
height: max(1, availableHeight / digitRows)
|
||||||
|
)
|
||||||
|
|
||||||
|
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
|
||||||
|
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
|
||||||
|
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
|
||||||
|
|
||||||
|
return FontUtils.calculateOptimalFontSize(
|
||||||
|
digit: "8",
|
||||||
|
fontName: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign,
|
||||||
|
for: digitSize,
|
||||||
|
isDisplayMode: isDisplayMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var estimated = calculateFontSize(reservingColonSize: 0)
|
||||||
|
for _ in 0..<2 {
|
||||||
|
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||||||
|
estimated = calculateFontSize(reservingColonSize: dotDiameter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !portrait {
|
||||||
|
// Verify colon height fits
|
||||||
|
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||||||
|
let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
|
||||||
|
let colonHeight = (dotDiameter * 2) + dotSpacing
|
||||||
|
if colonHeight > containerSize.height {
|
||||||
|
estimated *= containerSize.height / colonHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Verify total width fits to prevent clipping
|
||||||
|
// Calculate actual widths that will be used in layout
|
||||||
|
let actualDigitWidth = FontUtils.digitWidth(
|
||||||
|
fontName: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign,
|
||||||
|
fontSize: estimated
|
||||||
|
)
|
||||||
|
let actualColonWidth = estimated * Layout.dotDiameterMultiplier
|
||||||
|
let segmentCount: CGFloat = showSeconds ? 3.0 : 2.0
|
||||||
|
let totalWidth = (actualDigitWidth * 2 * segmentCount) + (colonCount * actualColonWidth)
|
||||||
|
|
||||||
|
// If total width exceeds container, scale down
|
||||||
|
if totalWidth > containerSize.width {
|
||||||
|
let scaleFactor = containerSize.width / totalWidth
|
||||||
|
estimated *= scaleFactor * 0.98 // Add 2% margin
|
||||||
|
Design.debugLog("[clockLayout] width overflow: totalWidth=\(Int(totalWidth)) container=\(Int(containerSize.width)) scaling by \(String(format: "%.2f", scaleFactor))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||||
|
|
||||||
|
if abs(estimated - fontSize) > 1 {
|
||||||
|
fontSize = estimated
|
||||||
|
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
|
||||||
|
} else {
|
||||||
|
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
|
||||||
|
}
|
||||||
|
lastCalculatedContainerSize = containerSize
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -156,14 +334,13 @@ struct TimeDisplayView: View {
|
|||||||
showAmPm: true,
|
showAmPm: true,
|
||||||
digitColor: .white,
|
digitColor: .white,
|
||||||
glowIntensity: 0.2,
|
glowIntensity: 0.2,
|
||||||
manualScale: 1.0,
|
|
||||||
stretched: true,
|
|
||||||
clockOpacity: 1.0,
|
clockOpacity: 1.0,
|
||||||
fontFamily: .verdana,
|
fontFamily: .verdana,
|
||||||
fontWeight: .medium,
|
fontWeight: .medium,
|
||||||
fontDesign: .default,
|
fontDesign: .default,
|
||||||
forceHorizontalMode: false,
|
forceHorizontalMode: false,
|
||||||
isDisplayMode: false
|
isDisplayMode: false,
|
||||||
|
animationStyle: .spring
|
||||||
)
|
)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,14 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
|
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
|
||||||
struct TimeSegment: View {
|
struct TimeSegment: View {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
private static let debugShowBorders = false
|
||||||
|
|
||||||
let text: String
|
let text: String
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
let opacity: Double
|
let opacity: Double
|
||||||
@ -19,9 +24,18 @@ struct TimeSegment: View {
|
|||||||
let fontWeight: Font.Weight
|
let fontWeight: Font.Weight
|
||||||
let fontDesign: Font.Design
|
let fontDesign: Font.Design
|
||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
|
let animationStyle: DigitAnimationStyle
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
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
|
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
||||||
DigitView(
|
DigitView(
|
||||||
digit: String(character),
|
digit: String(character),
|
||||||
@ -32,13 +46,14 @@ struct TimeSegment: View {
|
|||||||
opacity: clampedOpacity,
|
opacity: clampedOpacity,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $fontSize,
|
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)
|
.debugBorder(Self.debugShowBorders, color: .green, label: "Segment")
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -59,7 +74,8 @@ struct TimeSegment: View {
|
|||||||
fontFamily: .system,
|
fontFamily: .system,
|
||||||
fontWeight: .regular,
|
fontWeight: .regular,
|
||||||
fontDesign: .default,
|
fontDesign: .default,
|
||||||
isDisplayMode: true
|
isDisplayMode: true,
|
||||||
|
animationStyle: .spring
|
||||||
)
|
)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ struct SoundCategoryView: View {
|
|||||||
let sounds: [Sound]
|
let sounds: [Sound]
|
||||||
@Binding var selectedSound: Sound?
|
@Binding var selectedSound: Sound?
|
||||||
@State private var selectedCategory: SoundCategory = .all
|
@State private var selectedCategory: SoundCategory = .all
|
||||||
@State private var searchText: String = ""
|
@Binding var searchText: String
|
||||||
@State private var viewModel = SoundViewModel()
|
@State private var viewModel = SoundViewModel()
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -69,9 +69,6 @@ struct SoundCategoryView: View {
|
|||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
// Search Bar
|
|
||||||
searchBar
|
|
||||||
|
|
||||||
// Category Tabs
|
// Category Tabs
|
||||||
categoryTabs
|
categoryTabs
|
||||||
|
|
||||||
@ -83,24 +80,6 @@ struct SoundCategoryView: View {
|
|||||||
.padding(.top, Design.Spacing.medium)
|
.padding(.top, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subviews
|
|
||||||
private var searchBar: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
TextField("Search sounds...", text: $searchText)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
|
||||||
.padding(.vertical, Design.Spacing.small)
|
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var categoryTabs: some View {
|
private var categoryTabs: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
@ -257,7 +236,8 @@ struct SoundCard: View {
|
|||||||
Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"),
|
Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"),
|
||||||
Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound")
|
Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound")
|
||||||
],
|
],
|
||||||
selectedSound: .constant(nil)
|
selectedSound: .constant(nil),
|
||||||
|
searchText: .constant("")
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,11 @@ struct NoiseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
private var isPad: Bool {
|
||||||
|
UIDevice.current.userInterfaceIdiom == .pad
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -42,6 +47,11 @@ struct NoiseView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
||||||
|
.searchable(
|
||||||
|
text: $searchText,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .automatic),
|
||||||
|
prompt: "Search sounds"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -64,13 +74,23 @@ struct NoiseView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Fixed header
|
// Fixed header
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
Text("Ambient Sounds")
|
|
||||||
.sectionTitleStyle()
|
|
||||||
|
|
||||||
// Playback controls - always visible when sound is selected
|
// Playback controls - always visible when sound is selected
|
||||||
if selectedSound != nil {
|
if selectedSound != nil {
|
||||||
soundControlView
|
soundControlView
|
||||||
.centered()
|
.centered()
|
||||||
|
} else {
|
||||||
|
// Placeholder when no sound selected
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("Select a sound to begin")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.large)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentPadding(horizontal: Design.Spacing.large)
|
.contentPadding(horizontal: Design.Spacing.large)
|
||||||
@ -81,7 +101,8 @@ struct NoiseView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
SoundCategoryView(
|
SoundCategoryView(
|
||||||
sounds: viewModel.availableSounds,
|
sounds: viewModel.availableSounds,
|
||||||
selectedSound: $selectedSound
|
selectedSound: $selectedSound,
|
||||||
|
searchText: $searchText
|
||||||
)
|
)
|
||||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||||
}
|
}
|
||||||
@ -92,8 +113,10 @@ struct NoiseView: View {
|
|||||||
HStack(spacing: Design.Spacing.large) {
|
HStack(spacing: Design.Spacing.large) {
|
||||||
// Left side: Player controls
|
// Left side: Player controls
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
if !isPad {
|
||||||
Text("Ambient Sounds")
|
Text("Ambient Sounds")
|
||||||
.sectionTitleStyle()
|
.sectionTitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
if selectedSound != nil {
|
if selectedSound != nil {
|
||||||
soundControlView
|
soundControlView
|
||||||
@ -123,7 +146,8 @@ struct NoiseView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
SoundCategoryView(
|
SoundCategoryView(
|
||||||
sounds: viewModel.availableSounds,
|
sounds: viewModel.availableSounds,
|
||||||
selectedSound: $selectedSound
|
selectedSound: $selectedSound,
|
||||||
|
searchText: $searchText
|
||||||
)
|
)
|
||||||
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
.contentPadding(horizontal: Design.Spacing.large, vertical: Design.Spacing.large)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 digitColorHex = "#FFFFFF"
|
||||||
static let backgroundColorHex = "#000000"
|
static let backgroundColorHex = "#000000"
|
||||||
static let glowIntensity = 0.6
|
static let glowIntensity = 0.6
|
||||||
static let digitScale = 1.0
|
|
||||||
static let clockOpacity = 0.5
|
static let clockOpacity = 0.5
|
||||||
static let overlayOpacity = 0.5
|
static let overlayOpacity = 0.5
|
||||||
static let minFontSize = 20.0
|
static let minFontSize = 20.0
|
||||||
|
|||||||
@ -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 {
|
var percentageDownsize: CGFloat {
|
||||||
switch self {
|
// All fonts now use 1.0 since we have accurate glyph measurement
|
||||||
case .system:
|
return 1.0
|
||||||
return 0.95
|
|
||||||
case .georgia:
|
|
||||||
return 0.90
|
|
||||||
default: return 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var fontWeights: [Font.Weight] {
|
var fontWeights: [Font.Weight] {
|
||||||
|
|||||||
@ -8,10 +8,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CoreText
|
||||||
|
|
||||||
/// Font sizing and typography utilities
|
/// Font sizing and typography utilities
|
||||||
struct FontUtils {
|
struct FontUtils {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
/// Safety margin to prevent clipping (1.0 = no margin, 0.98 = 2% margin)
|
||||||
|
private static let safetyMargin: CGFloat = 0.98
|
||||||
|
/// When true, logs font sizing calculations
|
||||||
|
private static let debugLogging = false
|
||||||
|
|
||||||
static func calculateOptimalFontSize(
|
static func calculateOptimalFontSize(
|
||||||
digit: String,
|
digit: String,
|
||||||
fontName: FontFamily,
|
fontName: FontFamily,
|
||||||
@ -23,7 +30,7 @@ struct FontUtils {
|
|||||||
var low: CGFloat = 1.0
|
var low: CGFloat = 1.0
|
||||||
var high: CGFloat = 2000.0
|
var high: CGFloat = 2000.0
|
||||||
|
|
||||||
while high - low > 0.01 {
|
while high - low > 0.5 {
|
||||||
let mid = (low + high) / 2
|
let mid = (low + high) / 2
|
||||||
let testFont = createUIFont(
|
let testFont = createUIFont(
|
||||||
name: fontName,
|
name: fontName,
|
||||||
@ -31,7 +38,7 @@ struct FontUtils {
|
|||||||
design: design,
|
design: design,
|
||||||
size: mid
|
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 {
|
if textSize.width <= size.width && textSize.height <= size.height {
|
||||||
low = mid
|
low = mid
|
||||||
@ -40,9 +47,60 @@ struct FontUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply more conservative sizing in full screen mode
|
// Apply minimal safety margin - removed excessive downsize factors
|
||||||
let baseSize = low * fontName.percentageDownsize
|
let finalSize = low * safetyMargin
|
||||||
return isDisplayMode ? baseSize * 0.95 : baseSize
|
|
||||||
|
if debugLogging {
|
||||||
|
let testFont = createUIFont(name: fontName, weight: weight, design: design, size: finalSize)
|
||||||
|
let glyphSize = glyphBoundingBox(for: digit, withFont: testFont)
|
||||||
|
print("[FontUtils] target=\(Int(size.width))×\(Int(size.height)) glyph=\(Int(glyphSize.width))×\(Int(glyphSize.height)) fontSize=\(Int(finalSize))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the fixed width for a single digit at the given font size
|
||||||
|
/// Uses "8" as the reference since it's typically the widest digit
|
||||||
|
static func digitWidth(
|
||||||
|
fontName: FontFamily,
|
||||||
|
weight: Font.Weight,
|
||||||
|
design: Font.Design,
|
||||||
|
fontSize: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
let font = createUIFont(name: fontName, weight: weight, design: design, size: fontSize)
|
||||||
|
return glyphBoundingBox(for: "8", withFont: font).width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the text size using typographic bounds that match SwiftUI rendering
|
||||||
|
private static func glyphBoundingBox(
|
||||||
|
for text: String,
|
||||||
|
withFont font: UIFont
|
||||||
|
) -> CGSize {
|
||||||
|
// Get the glyph advances (actual width of each character)
|
||||||
|
let ctFont = font as CTFont
|
||||||
|
var glyphs = [CGGlyph](repeating: 0, count: text.count)
|
||||||
|
var characters = [UniChar](text.utf16)
|
||||||
|
CTFontGetGlyphsForCharacters(ctFont, &characters, &glyphs, text.count)
|
||||||
|
|
||||||
|
var advances = [CGSize](repeating: .zero, count: glyphs.count)
|
||||||
|
CTFontGetAdvancesForGlyphs(ctFont, .horizontal, glyphs, &advances, glyphs.count)
|
||||||
|
|
||||||
|
let totalWidth = advances.reduce(0) { $0 + $1.width }
|
||||||
|
|
||||||
|
// Use ascender height for proper top clearance
|
||||||
|
// This matches what SwiftUI Text renders more closely
|
||||||
|
let ascender = font.ascender
|
||||||
|
let descender = abs(font.descender)
|
||||||
|
|
||||||
|
// For digits, we don't have descenders, but SwiftUI still allocates space
|
||||||
|
// Use ascender + small fraction of descender for safety
|
||||||
|
let height = ascender + (descender * 0.3)
|
||||||
|
|
||||||
|
if debugLogging {
|
||||||
|
print("[FontUtils] text='\(text)' width=\(Int(totalWidth)) ascender=\(Int(ascender)) height=\(Int(height))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGSize(width: ceil(totalWidth), height: ceil(height))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createFont(
|
static func createFont(
|
||||||
@ -178,14 +236,13 @@ private struct TestContentView: View {
|
|||||||
showAmPm: true,
|
showAmPm: true,
|
||||||
digitColor: .primary,
|
digitColor: .primary,
|
||||||
glowIntensity: 0.5,
|
glowIntensity: 0.5,
|
||||||
manualScale: 1.0,
|
|
||||||
stretched: false,
|
|
||||||
clockOpacity: 1.0,
|
clockOpacity: 1.0,
|
||||||
fontFamily: font,
|
fontFamily: font,
|
||||||
fontWeight: fontWeight,
|
fontWeight: fontWeight,
|
||||||
fontDesign: fontDesign,
|
fontDesign: fontDesign,
|
||||||
forceHorizontalMode: true,
|
forceHorizontalMode: true,
|
||||||
isDisplayMode: false)
|
isDisplayMode: false,
|
||||||
|
animationStyle: .spring)
|
||||||
|
|
||||||
TimeSegment(
|
TimeSegment(
|
||||||
text: digit,
|
text: digit,
|
||||||
@ -196,7 +253,8 @@ private struct TestContentView: View {
|
|||||||
fontFamily: font, // FontFamily
|
fontFamily: font, // FontFamily
|
||||||
fontWeight: fontWeight,
|
fontWeight: fontWeight,
|
||||||
fontDesign: fontDesign,
|
fontDesign: fontDesign,
|
||||||
isDisplayMode: false
|
isDisplayMode: false,
|
||||||
|
animationStyle: .spring
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: 400, height: 200)
|
.frame(width: 400, height: 200)
|
||||||
@ -262,7 +320,8 @@ private struct FontSampleCell: View {
|
|||||||
fontFamily: font, // FontFamily
|
fontFamily: font, // FontFamily
|
||||||
fontWeight: weight,
|
fontWeight: weight,
|
||||||
fontDesign: design,
|
fontDesign: design,
|
||||||
isDisplayMode: false)
|
isDisplayMode: false,
|
||||||
|
animationStyle: .spring)
|
||||||
.frame(width: 100, height: 100)
|
.frame(width: 100, height: 100)
|
||||||
.border(Color.black)
|
.border(Color.black)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user