Compare commits
No commits in common. "301d31cb77a2dcc4ca837d342d6d8aba5cceaea7" and "a88a02b6710f3b95ce2b78a96ec825b5f23adf82" have entirely different histories.
301d31cb77
...
a88a02b671
@ -1,9 +0,0 @@
|
|||||||
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,19 +19,11 @@ 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 on iPhone and full-width layout on iPad
|
- **Safe area handling** with proper Dynamic Island avoidance
|
||||||
- **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%)
|
||||||
@ -52,7 +44,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 hidden on the Clock tab (including full-screen mode)
|
- **Status bar control**: Status bar automatically hidden in 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
|
||||||
@ -144,13 +136,12 @@ 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**: Status bar remains hidden while on the Clock tab
|
- **Status bar hiding**: Automatic status bar hiding in full-screen mode
|
||||||
- **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
|
||||||
@ -333,7 +324,6 @@ 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
|
||||||
@ -566,14 +556,13 @@ 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 system search in the navigation bar (iPhone and iPad)
|
1. **Sound Selection**: Browse sounds by category with search functionality
|
||||||
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. **Empty state guidance**: Prompt shown when no sound is selected
|
7. **Responsive layout**: Optimized for portrait and landscape orientations
|
||||||
8. **Responsive layout**: Optimized for portrait and landscape orientations
|
|
||||||
|
|
||||||
## Technical Requirements
|
## Technical Requirements
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,6 @@ 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>1</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -52,17 +52,14 @@ 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,7 +18,6 @@ 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 {
|
||||||
@ -51,8 +50,7 @@ struct AlarmView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(isPad ? "" : "Alarms")
|
.navigationTitle("Alarms")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@ -23,6 +23,8 @@ 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
|
||||||
@ -41,7 +43,6 @@ 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
|
||||||
@ -67,6 +68,8 @@ 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
|
||||||
@ -79,7 +82,6 @@ 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
|
||||||
@ -105,6 +107,8 @@ 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
|
||||||
@ -127,10 +131,6 @@ 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,6 +151,8 @@ 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)
|
||||||
@ -163,7 +165,6 @@ 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)
|
||||||
@ -444,6 +445,8 @@ 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 &&
|
||||||
@ -456,7 +459,6 @@ 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,14 +69,6 @@ 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
|
||||||
@ -87,6 +79,8 @@ 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
|
||||||
@ -105,7 +99,6 @@ 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,7 +27,6 @@ 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(
|
||||||
@ -102,7 +101,7 @@ struct ClockSettingsView: View {
|
|||||||
.padding(.bottom, Design.Spacing.xxxLarge)
|
.padding(.bottom, Design.Spacing.xxxLarge)
|
||||||
}
|
}
|
||||||
.background(AppSurface.primary)
|
.background(AppSurface.primary)
|
||||||
.navigationTitle(isPad ? "" : "Settings")
|
.navigationTitle("Clock Settings")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
digitColor = Color(hex: style.digitColorHex) ?? .white
|
digitColor = Color(hex: style.digitColorHex) ?? .white
|
||||||
|
|||||||
@ -11,73 +11,45 @@ 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 {
|
||||||
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 {
|
||||||
// Background extends to full screen
|
|
||||||
viewModel.style.effectiveBackgroundColor
|
viewModel.style.effectiveBackgroundColor
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
// Main clock display container with symmetric padding for Dynamic Island
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// Main clock display container
|
||||||
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 with symmetric padding
|
// Top overlay container
|
||||||
ClockOverlayContainer(style: viewModel.style)
|
ClockOverlayContainer(style: viewModel.style)
|
||||||
.padding(.leading, symmetricInset)
|
|
||||||
.padding(.trailing, symmetricInset)
|
// Full screen hint overlay
|
||||||
|
if showFullScreenHint {
|
||||||
|
FullScreenHintView(isDisplayMode: viewModel.isDisplayMode)
|
||||||
}
|
}
|
||||||
.frame(width: screenWidth, height: screenHeight)
|
}
|
||||||
.overlay(alignment: .bottomLeading) {
|
}
|
||||||
if Self.debugShowSafeAreas {
|
}
|
||||||
safeAreaDebugInfo(
|
.ignoresSafeArea(.all, edges: viewModel.isDisplayMode ? .bottom : [])
|
||||||
size: geometry.size,
|
.statusBarHidden(viewModel.isDisplayMode)
|
||||||
windowInsets: windowInsets,
|
.overlay {
|
||||||
symmetricInset: symmetricInset
|
// Toolbar overlay
|
||||||
|
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)
|
||||||
@ -123,59 +95,18 @@ 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,12 +18,19 @@ struct ClockDisplayContainer: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
return 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
|
||||||
|
|
||||||
// Time display - fills all available space
|
VStack(spacing: 0) {
|
||||||
|
// 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,
|
||||||
@ -31,17 +38,19 @@ 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,10 +15,8 @@ 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 || isPad ? "" : "Clock")
|
.navigationTitle(isDisplayMode ? "" : "Clock")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationBarBackButtonHidden(isDisplayMode)
|
.navigationBarBackButtonHidden(isDisplayMode)
|
||||||
.toolbar(isDisplayMode ? .hidden : .automatic)
|
.toolbar(isDisplayMode ? .hidden : .automatic)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,12 +35,6 @@ 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,13 +6,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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
|
||||||
@ -22,9 +19,11 @@ 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,
|
||||||
@ -33,8 +32,7 @@ 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
|
||||||
@ -43,16 +41,18 @@ 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,106 +64,75 @@ struct DigitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var glowText: some View {
|
private var glowText: some View {
|
||||||
baseText
|
text
|
||||||
.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 {
|
||||||
baseText
|
text
|
||||||
.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 baseText: some View {
|
private var text: 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))
|
||||||
.lineLimit(1)
|
.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)
|
||||||
}
|
}
|
||||||
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
|
calculateOptimalFontSize(for: newSize)
|
||||||
}
|
}
|
||||||
|
.onChange(of: sizeCategory) { _, _ in
|
||||||
// MARK: - Animation Modifiers
|
calculateOptimalFontSize(for: geometry.size)
|
||||||
|
|
||||||
private struct DigitAnimationModifier: ViewModifier {
|
|
||||||
let style: DigitAnimationStyle
|
|
||||||
let digit: String
|
|
||||||
let fontSize: CGFloat
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
switch style {
|
|
||||||
case .none:
|
|
||||||
content
|
|
||||||
case .spring:
|
|
||||||
content
|
|
||||||
.contentTransition(.numericText())
|
|
||||||
.animation(.snappy(duration: 0.35), value: digit)
|
|
||||||
.phaseAnimator([0, 1], trigger: digit) { content, phase in
|
|
||||||
content
|
|
||||||
.scaleEffect(phase == 1 ? 1.05 : 1.0)
|
|
||||||
.offset(y: phase == 1 ? -fontSize * 0.02 : 0)
|
|
||||||
} animation: { _ in
|
|
||||||
.spring(duration: 0.3, bounce: 0.4)
|
|
||||||
}
|
}
|
||||||
case .bounce:
|
.onChange(of: fontName) { _, _ in
|
||||||
content
|
calculateOptimalFontSize(for: geometry.size)
|
||||||
.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:
|
.onChange(of: weight) { _, _ in
|
||||||
content
|
calculateOptimalFontSize(for: geometry.size)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: design) { _, _ in
|
||||||
|
calculateOptimalFontSize(for: geometry.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GlowAnimationModifier: ViewModifier {
|
private func calculateOptimalFontSize(for size: CGSize) {
|
||||||
let style: DigitAnimationStyle
|
// Prevent multiple calculations for the same size
|
||||||
let digit: String
|
guard size != lastCalculatedSize && !isCalculating else { return }
|
||||||
let glowRadius: CGFloat
|
|
||||||
let glowOpacity: Double
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
// Prevent multiple updates per frame
|
||||||
switch style {
|
guard !isCalculating else { return }
|
||||||
case .none:
|
isCalculating = true
|
||||||
content
|
|
||||||
case .spring, .bounce:
|
let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit,
|
||||||
content
|
fontName: fontName,
|
||||||
.phaseAnimator([0, 1], trigger: digit) { content, phase in
|
weight: weight,
|
||||||
content
|
design: design,
|
||||||
.blur(radius: glowRadius * (phase == 1 ? 1.5 : 1.0))
|
for: size,
|
||||||
.opacity(glowOpacity * (phase == 1 ? 1.2 : 1.0))
|
isDisplayMode: isDisplayMode)
|
||||||
} animation: { _ in
|
|
||||||
.easeInOut(duration: 0.3)
|
// Only update if the size is significantly different to prevent micro-adjustments
|
||||||
}
|
fontSize = optimalSize
|
||||||
case .glitch:
|
lastCalculatedSize = size
|
||||||
content
|
|
||||||
.phaseAnimator([0, 1, 2, 0], trigger: digit) { content, phase in
|
// Reset calculation flag after a brief delay to allow for frame completion
|
||||||
content
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { // ~60fps
|
||||||
.opacity(phase == 1 || phase == 2 ? 0.4 : 1.0)
|
isCalculating = false
|
||||||
.blur(radius: glowRadius * (phase == 1 || phase == 2 ? 2.0 : 1.0))
|
|
||||||
} animation: { _ in
|
|
||||||
.easeInOut(duration: 0.1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,8 +151,7 @@ private struct GlowAnimationModifier: ViewModifier {
|
|||||||
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",
|
||||||
@ -191,8 +159,7 @@ private struct GlowAnimationModifier: ViewModifier {
|
|||||||
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(":")
|
||||||
@ -204,8 +171,7 @@ private struct GlowAnimationModifier: ViewModifier {
|
|||||||
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",
|
||||||
@ -213,8 +179,7 @@ private struct GlowAnimationModifier: ViewModifier {
|
|||||||
weight: weight,
|
weight: weight,
|
||||||
design: design,
|
design: design,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $sharedFontSize,
|
fontSize: $sharedFontSize)
|
||||||
animationStyle: .spring)
|
|
||||||
.border(Color.black)
|
.border(Color.black)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// 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,21 +20,6 @@ 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",
|
||||||
@ -42,6 +27,25 @@ 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,18 +11,6 @@ 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
|
||||||
@ -30,22 +18,15 @@ 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 = {
|
||||||
@ -90,73 +71,49 @@ 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 - use debug static values if enabled
|
// Time components
|
||||||
let hour: String
|
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||||||
let minute: String
|
let minute = Self.minuteDF.string(from: date)
|
||||||
let secondsText: String
|
let secondsText = Self.secondDF.string(from: date)
|
||||||
|
|
||||||
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 - hide in debug mode
|
// AM/PM badge
|
||||||
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
|
let shouldShowAmPm = showAmPm && !use24Hour
|
||||||
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
|
let amPmText = 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 * Layout.dotDiameterMultiplier
|
let dotDiameter = fontSize * 0.75
|
||||||
let dotSpacing = portrait
|
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
|
||||||
? fontSize * Layout.dotSpacingPortraitMultiplier
|
|
||||||
: fontSize * Layout.dotSpacingLandscapeMultiplier
|
// Simple scaling - let the content size naturally and apply manual scale
|
||||||
|
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, spacing: 0) {
|
VStack(alignment: .center) {
|
||||||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
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, animationStyle: animationStyle)
|
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
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, animationStyle: animationStyle)
|
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity) // Center horizontally in portrait
|
|
||||||
} else {
|
} else {
|
||||||
// Landscape mode - use fixed digit widths for stable layout
|
HStack(alignment: .center) {
|
||||||
// Each digit gets the same width (based on "8") so clock doesn't shift
|
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
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)
|
||||||
.frame(width: dotDiameter)
|
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
|
||||||
.frame(width: segmentWidth)
|
|
||||||
if showSeconds {
|
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)
|
||||||
.frame(width: dotDiameter)
|
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode, animationStyle: animationStyle)
|
|
||||||
.frame(width: segmentWidth)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Center in landscape
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if shouldShowAmPm {
|
if shouldShowAmPm {
|
||||||
Text(amPmText)
|
Text(amPmText)
|
||||||
@ -175,153 +132,18 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -334,13 +156,14 @@ 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,14 +7,9 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -24,18 +19,9 @@ 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 {
|
||||||
// Use fixed digit width based on widest digit ("8") for stable layout
|
HStack(alignment: .center, spacing: 0) {
|
||||||
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),
|
||||||
@ -46,14 +32,13 @@ struct TimeSegment: View {
|
|||||||
opacity: clampedOpacity,
|
opacity: clampedOpacity,
|
||||||
glowIntensity: glowIntensity,
|
glowIntensity: glowIntensity,
|
||||||
fontSize: $fontSize,
|
fontSize: $fontSize,
|
||||||
isDisplayMode: isDisplayMode,
|
isDisplayMode: isDisplayMode
|
||||||
animationStyle: animationStyle
|
|
||||||
)
|
)
|
||||||
.frame(width: fixedDigitWidth) // Fixed width for stability
|
//.border(.red, width: 1)
|
||||||
.debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.debugBorder(Self.debugShowBorders, color: .green, label: "Segment")
|
//.border(Color.green, width: 1)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -74,8 +59,7 @@ 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
|
||||||
@Binding var searchText: String
|
@State private var searchText: String = ""
|
||||||
@State private var viewModel = SoundViewModel()
|
@State private var viewModel = SoundViewModel()
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
@ -69,6 +69,9 @@ 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
|
||||||
|
|
||||||
@ -80,6 +83,24 @@ 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) {
|
||||||
@ -236,8 +257,7 @@ 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,11 +22,6 @@ 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 {
|
||||||
@ -47,11 +42,6 @@ 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
|
||||||
@ -74,23 +64,13 @@ 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)
|
||||||
@ -101,8 +81,7 @@ 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)
|
||||||
}
|
}
|
||||||
@ -113,10 +92,8 @@ 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
|
||||||
@ -146,8 +123,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,6 +50,7 @@ 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,11 +31,14 @@ 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 {
|
||||||
// All fonts now use 1.0 since we have accurate glyph measurement
|
switch self {
|
||||||
return 1.0
|
case .system:
|
||||||
|
return 0.95
|
||||||
|
case .georgia:
|
||||||
|
return 0.90
|
||||||
|
default: return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fontWeights: [Font.Weight] {
|
var fontWeights: [Font.Weight] {
|
||||||
|
|||||||
@ -8,17 +8,10 @@
|
|||||||
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,
|
||||||
@ -30,7 +23,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.5 {
|
while high - low > 0.01 {
|
||||||
let mid = (low + high) / 2
|
let mid = (low + high) / 2
|
||||||
let testFont = createUIFont(
|
let testFont = createUIFont(
|
||||||
name: fontName,
|
name: fontName,
|
||||||
@ -38,7 +31,7 @@ struct FontUtils {
|
|||||||
design: design,
|
design: design,
|
||||||
size: mid
|
size: mid
|
||||||
)
|
)
|
||||||
let textSize = glyphBoundingBox(for: digit, withFont: testFont)
|
let textSize = tightBoundingBox(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
|
||||||
@ -47,60 +40,9 @@ struct FontUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply minimal safety margin - removed excessive downsize factors
|
// Apply more conservative sizing in full screen mode
|
||||||
let finalSize = low * safetyMargin
|
let baseSize = low * fontName.percentageDownsize
|
||||||
|
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(
|
||||||
@ -236,13 +178,14 @@ 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,
|
||||||
@ -253,8 +196,7 @@ 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)
|
||||||
@ -320,8 +262,7 @@ 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