Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5a752d178a
commit
ed6e8c2635
60
PRD.md
60
PRD.md
@ -41,6 +41,8 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
- **Normal mode**: Standard interface with navigation and settings
|
- **Normal mode**: Standard interface with navigation and settings
|
||||||
- **Display mode**: Full-screen clock activated by long-press (0.6 seconds)
|
- **Display mode**: Full-screen clock activated by long-press (0.6 seconds)
|
||||||
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode
|
- **Automatic UI hiding**: Tab bar and navigation elements hide in display mode
|
||||||
|
- **iPad compatibility**: Uses SwiftUI's native `.toolbar(.hidden, for: .tabBar)` for proper iPad sidebar-style tab bar hiding
|
||||||
|
- **Cross-platform support**: Works correctly on both iPhone (bottom tab bar) and iPad (top sidebar tab bar)
|
||||||
- **Smooth transitions**: Animated transitions between modes
|
- **Smooth transitions**: Animated transitions between modes
|
||||||
- **Status bar control**: Status bar automatically hidden in 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
|
||||||
@ -328,6 +330,8 @@ These principles are fundamental to the project's long-term success and must be
|
|||||||
- **Interactive controls**: Toggles, sliders, color pickers
|
- **Interactive controls**: Toggles, sliders, color pickers
|
||||||
- **Real-time updates**: Changes apply immediately
|
- **Real-time updates**: Changes apply immediately
|
||||||
- **Sheet presentation**: Modal settings with detents
|
- **Sheet presentation**: Modal settings with detents
|
||||||
|
- **iPad optimization**: Settings sheet opens at full size (.large) on iPad for better usability
|
||||||
|
- **iPhone compatibility**: Settings sheet uses medium/large detents on iPhone for optimal space usage
|
||||||
|
|
||||||
## File Structure and Organization
|
## File Structure and Organization
|
||||||
|
|
||||||
@ -576,6 +580,62 @@ The following changes **automatically require** PRD updates:
|
|||||||
- **Weather integration**: Weather-based alarm sounds
|
- **Weather integration**: Weather-based alarm sounds
|
||||||
- **Health integration**: Sleep tracking integration
|
- **Health integration**: Sleep tracking integration
|
||||||
|
|
||||||
|
## Build and Development
|
||||||
|
|
||||||
|
### Terminal Build Commands
|
||||||
|
|
||||||
|
The following terminal commands are used for building and testing the project. These commands have been tested and work reliably:
|
||||||
|
|
||||||
|
#### Basic Build Commands
|
||||||
|
```bash
|
||||||
|
# Navigate to project directory
|
||||||
|
cd /Users/mattbruce/Documents/Projects/TheNoiseClock
|
||||||
|
|
||||||
|
# Build for iOS Simulator (iPad mini)
|
||||||
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build
|
||||||
|
|
||||||
|
# Build for iOS Simulator (any device)
|
||||||
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' build
|
||||||
|
|
||||||
|
# Build for physical device (requires provisioning profile)
|
||||||
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Checking Commands
|
||||||
|
```bash
|
||||||
|
# Check for build errors only (filtered output)
|
||||||
|
xcodebuild -project TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPad mini (A17 Pro),OS=18.1' build 2>&1 | grep -E "(error:|warning:|failed)" | head -10
|
||||||
|
|
||||||
|
# Quick syntax check for specific files
|
||||||
|
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeDisplayView.swift
|
||||||
|
swift -frontend -parse TheNoiseClock/Views/Clock/Components/TimeSegment.swift
|
||||||
|
swift -frontend -parse TheNoiseClock/Views/Clock/Components/DigitView.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Available Simulators
|
||||||
|
The following simulators are available for testing:
|
||||||
|
- **iPad mini (A17 Pro)** - Primary testing device
|
||||||
|
- **iPad (10th generation)**
|
||||||
|
- **iPad Air 11-inch (M2)**
|
||||||
|
- **iPad Air 13-inch (M2)**
|
||||||
|
- **iPad Pro 11-inch (M4)**
|
||||||
|
- **iPad Pro 13-inch (M4)**
|
||||||
|
- **iPhone 16, 16 Plus, 16 Pro, 16 Pro Max**
|
||||||
|
- **iPhone SE (3rd generation)**
|
||||||
|
|
||||||
|
#### Build Troubleshooting
|
||||||
|
1. **Provisioning Profile Errors**: Use iOS Simulator builds instead of device builds
|
||||||
|
2. **Missing Files**: Ensure all new Swift files are added to the Xcode project target
|
||||||
|
3. **Preview Compilation Errors**: Break down complex expressions into computed properties
|
||||||
|
4. **Package Dependencies**: AudioPlaybackKit is included as local package dependency
|
||||||
|
|
||||||
|
#### Development Workflow
|
||||||
|
1. **Make code changes** in Xcode or via AI assistant
|
||||||
|
2. **Test build** using terminal commands above
|
||||||
|
3. **Fix any errors** identified in build output
|
||||||
|
4. **Test on simulator** using Xcode or terminal build
|
||||||
|
5. **Update PRD** if architectural changes are made
|
||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
### Project Information
|
### Project Information
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -34,28 +34,6 @@ extension View {
|
|||||||
.disabled(!isEnabled)
|
.disabled(!isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide tab bar with animation
|
|
||||||
/// - Parameters:
|
|
||||||
/// - hidden: Whether to hide the tab bar
|
|
||||||
/// - animated: Whether to animate the change
|
|
||||||
func hideTabBar(_ hidden: Bool, animated: Bool = true) {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
||||||
let window = windowScene.windows.first,
|
|
||||||
let tabBarController = window.rootViewController?.findTabBarController() else { return }
|
|
||||||
|
|
||||||
let tabBar = tabBarController.tabBar
|
|
||||||
let changes = {
|
|
||||||
tabBar.alpha = hidden ? 0 : 1
|
|
||||||
}
|
|
||||||
if animated {
|
|
||||||
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
|
|
||||||
} else {
|
|
||||||
changes()
|
|
||||||
}
|
|
||||||
tabBar.isUserInteractionEnabled = !hidden
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply responsive font sizing that updates on orientation and layout changes
|
/// Apply responsive font sizing that updates on orientation and layout changes
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -155,22 +133,3 @@ struct OrientationChangeModifier: ViewModifier {
|
|||||||
.id(orientation.rawValue) // Force view recreation on orientation change
|
.id(orientation.rawValue) // Force view recreation on orientation change
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
// Made internal (module-wide) so it can be used from other files like ClockView.swift
|
|
||||||
extension UIViewController {
|
|
||||||
func findTabBarController() -> UITabBarController? {
|
|
||||||
if let tbc = self as? UITabBarController { return tbc }
|
|
||||||
for child in children {
|
|
||||||
if let tbc = child.findTabBarController() { return tbc }
|
|
||||||
}
|
|
||||||
if let presented = presentedViewController {
|
|
||||||
return presented.findTabBarController()
|
|
||||||
}
|
|
||||||
if let nav = self as? UINavigationController {
|
|
||||||
return nav.visibleViewController?.findTabBarController()
|
|
||||||
}
|
|
||||||
return parent?.findTabBarController()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|||||||
@ -40,7 +40,9 @@ struct ClockView: View {
|
|||||||
ClockSettingsView(style: viewModel.style) { newStyle in
|
ClockSettingsView(style: viewModel.style) { newStyle in
|
||||||
viewModel.updateStyle(newStyle)
|
viewModel.updateStyle(newStyle)
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents(UIDevice.current.userInterfaceIdiom == .pad ? [.large] : [.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackgroundInteraction(.enabled)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// Toolbar overlay
|
// Toolbar overlay
|
||||||
@ -51,7 +53,7 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// Tab bar management overlay
|
// Tab bar management overlay
|
||||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode, animated: true)
|
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// Gesture handling overlay
|
// Gesture handling overlay
|
||||||
@ -67,4 +69,6 @@ struct ClockView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClockView()
|
ClockView()
|
||||||
}
|
}
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,4 +60,6 @@ struct ClockDisplayContainer: View {
|
|||||||
style: ClockStyle(),
|
style: ClockStyle(),
|
||||||
isDisplayMode: false
|
isDisplayMode: false
|
||||||
)
|
)
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,48 +8,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Component that manages tab bar visibility for display mode
|
/// Component that manages tab bar visibility for display mode
|
||||||
|
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
||||||
struct ClockTabBarManager: View {
|
struct ClockTabBarManager: View {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
let animated: Bool
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.onAppear {
|
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
||||||
setTabBarHidden(isDisplayMode, animated: false)
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
setTabBarHidden(false, animated: false)
|
|
||||||
}
|
|
||||||
.onChange(of: isDisplayMode) { _, newValue in
|
|
||||||
setTabBarHidden(newValue, animated: animated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
private func setTabBarHidden(_ hidden: Bool, animated: Bool) {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
||||||
let window = windowScene.windows.first,
|
|
||||||
let tabBarController = window.rootViewController?.findTabBarController() else { return }
|
|
||||||
|
|
||||||
let tabBar = tabBarController.tabBar
|
|
||||||
let changes = {
|
|
||||||
tabBar.alpha = hidden ? 0 : 1
|
|
||||||
}
|
|
||||||
if animated {
|
|
||||||
UIView.animate(withDuration: AppConstants.AnimationDurations.short, animations: changes)
|
|
||||||
} else {
|
|
||||||
changes()
|
|
||||||
}
|
|
||||||
tabBar.isUserInteractionEnabled = !hidden
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
ClockTabBarManager(isDisplayMode: false, animated: false)
|
ClockTabBarManager(isDisplayMode: false)
|
||||||
}
|
}
|
||||||
|
|||||||
88
TheNoiseClock/Views/Clock/Components/DigitView.swift
Normal file
88
TheNoiseClock/Views/Clock/Components/DigitView.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// DigitView.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/9/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying a single digit with fixed width and glow effects
|
||||||
|
struct DigitView: View {
|
||||||
|
let digit: String
|
||||||
|
let fontSize: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let fontFamily: String
|
||||||
|
let fontWeight: String
|
||||||
|
let fontDesign: String
|
||||||
|
let digitWidth: CGFloat
|
||||||
|
let digitHeight: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
glowText
|
||||||
|
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||||
|
mainText
|
||||||
|
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: digitWidth, height: digitHeight)
|
||||||
|
.border(Color.blue, width: 2) // DEBUG: Blue border around individual digits
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
private var customFont: Font {
|
||||||
|
FontUtils.customFont(
|
||||||
|
size: fontSize,
|
||||||
|
family: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var glowRadius: CGFloat {
|
||||||
|
ColorUtils.glowRadius(intensity: glowIntensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var glowOpacity: Double {
|
||||||
|
ColorUtils.glowOpacity(intensity: glowIntensity) * opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
private var glowText: some View {
|
||||||
|
Text(digit)
|
||||||
|
.font(customFont)
|
||||||
|
.foregroundColor(digitColor)
|
||||||
|
.blur(radius: glowRadius)
|
||||||
|
.opacity(glowOpacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainText: some View {
|
||||||
|
Text(digit)
|
||||||
|
.font(customFont)
|
||||||
|
.foregroundColor(digitColor)
|
||||||
|
.opacity(opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
let digitView = DigitView(
|
||||||
|
digit: "8",
|
||||||
|
fontSize: 80,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.2,
|
||||||
|
fontFamily: "System",
|
||||||
|
fontWeight: "Regular",
|
||||||
|
fontDesign: "Default",
|
||||||
|
digitWidth: 60,
|
||||||
|
digitHeight: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
return digitView
|
||||||
|
.background(Color.black)
|
||||||
|
.frame(width: 100, height: 120)
|
||||||
|
}
|
||||||
78
TheNoiseClock/Views/Clock/Components/DotCircle.swift
Normal file
78
TheNoiseClock/Views/Clock/Components/DotCircle.swift
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// DotCircle.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/9/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying a single dot in the colon separator
|
||||||
|
struct DotCircle: View {
|
||||||
|
let size: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let fontWeight: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Calculate size based on font weight - make dots smaller for lighter weights
|
||||||
|
let sizeMultiplier = FontUtils.dotSizeMultiplier(for: fontWeight)
|
||||||
|
let adjustedSize = size * sizeMultiplier
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(digitColor)
|
||||||
|
.frame(width: adjustedSize, height: adjustedSize)
|
||||||
|
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
||||||
|
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
||||||
|
Circle()
|
||||||
|
.fill(digitColor)
|
||||||
|
.frame(width: adjustedSize, height: adjustedSize)
|
||||||
|
.opacity(opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview("Small Dot") {
|
||||||
|
DotCircle(
|
||||||
|
size: 8,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Light"
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Large Dot") {
|
||||||
|
DotCircle(
|
||||||
|
size: 12,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Bold"
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Multiple Dots") {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
DotCircle(
|
||||||
|
size: 10,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Regular"
|
||||||
|
)
|
||||||
|
DotCircle(
|
||||||
|
size: 10,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Regular"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
41
TheNoiseClock/Views/Clock/Components/HorizontalColon.swift
Normal file
41
TheNoiseClock/Views/Clock/Components/HorizontalColon.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// HorizontalColon.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/9/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying horizontal colon separator (two dots side by side)
|
||||||
|
struct HorizontalColon: View {
|
||||||
|
let dotDiameter: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let fontWeight: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
HorizontalColon(
|
||||||
|
dotDiameter: 12,
|
||||||
|
spacing: 8,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Regular"
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
@ -52,12 +52,6 @@ struct TimeDisplayView: View {
|
|||||||
return df
|
return df
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let ampmDF: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
df.dateFormat = "a"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -89,14 +83,11 @@ struct TimeDisplayView: View {
|
|||||||
showSeconds: showSeconds,
|
showSeconds: showSeconds,
|
||||||
showAmPm: false
|
showAmPm: false
|
||||||
)
|
)
|
||||||
let ampmFontSize = FontUtils.ampmFontSize(baseFontSize: baseFontSize)
|
|
||||||
|
|
||||||
// Time components
|
// Time components
|
||||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||||||
let minute = Self.minuteDF.string(from: date)
|
let minute = Self.minuteDF.string(from: date)
|
||||||
let secondsText = Self.secondDF.string(from: date)
|
let secondsText = Self.secondDF.string(from: date)
|
||||||
let ampmText = Self.ampmDF.string(from: date)
|
|
||||||
let showAMPM = false // Always use colon/dots instead of AM/PM
|
|
||||||
|
|
||||||
// Calculate sizes using fixed-width approach to prevent jumping
|
// Calculate sizes using fixed-width approach to prevent jumping
|
||||||
let digitUIFont = FontUtils.customUIFont(
|
let digitUIFont = FontUtils.customUIFont(
|
||||||
@ -105,35 +96,10 @@ struct TimeDisplayView: View {
|
|||||||
weight: fontWeight,
|
weight: fontWeight,
|
||||||
design: fontDesign
|
design: fontDesign
|
||||||
)
|
)
|
||||||
let ampmUIFont = FontUtils.customUIFont(
|
|
||||||
size: ampmFontSize,
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
design: fontDesign
|
|
||||||
)
|
|
||||||
|
|
||||||
// Calculate consistent sizes for layout
|
// Calculate consistent sizes for layout
|
||||||
let _ = measureText("8", font: digitUIFont).height // Use 8 as reference height
|
let _ = measureText("8", font: digitUIFont).height // Use 8 as reference height
|
||||||
|
|
||||||
// Calculate the width of "88" for consistent sizing
|
|
||||||
let testFont = FontUtils.customUIFont(
|
|
||||||
size: baseFontSize,
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
design: fontDesign
|
|
||||||
)
|
|
||||||
let _ = calculateMaxTextWidth(font: testFont)
|
|
||||||
|
|
||||||
// Calculate width and height for a single digit (using "8" as the reference)
|
|
||||||
let singleDigitWidth = calculateMaxTextWidth(font: testFont, text: "8")
|
|
||||||
let singleDigitHeight = calculateMaxTextHeight(font: testFont, text: "8")
|
|
||||||
|
|
||||||
// All time segments use the same fixed width and height to prevent shifting
|
|
||||||
let hourSize = CGSize(width: singleDigitWidth * 2, height: singleDigitHeight)
|
|
||||||
let minuteSize = CGSize(width: singleDigitWidth * 2, height: singleDigitHeight)
|
|
||||||
let secondsSize = showSeconds ? CGSize(width: singleDigitWidth * 2, height: singleDigitHeight) : .zero
|
|
||||||
let ampmSize = showAMPM ? measureText(ampmText, font: ampmUIFont) : .zero
|
|
||||||
|
|
||||||
// Separators - reasonable spacing with extra padding in landscape
|
// Separators - reasonable spacing with extra padding in landscape
|
||||||
let dotDiameter = baseFontSize * 0.20
|
let dotDiameter = baseFontSize * 0.20
|
||||||
let hSpacing = portrait ? baseFontSize * 0.18 : baseFontSize * 0.25 // More spacing in landscape
|
let hSpacing = portrait ? baseFontSize * 0.18 : baseFontSize * 0.25 // More spacing in landscape
|
||||||
@ -141,17 +107,12 @@ struct TimeDisplayView: View {
|
|||||||
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
|
let horizontalSepSize = CGSize(width: dotDiameter * 2 + hSpacing, height: dotDiameter)
|
||||||
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
|
let verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
|
||||||
|
|
||||||
// Calculate layout
|
// Calculate layout - simplified without AM/PM
|
||||||
let (totalWidth, totalHeight) = calculateLayoutSize(
|
let (totalWidth, totalHeight) = calculateLayoutSize(
|
||||||
portrait: portrait,
|
portrait: portrait,
|
||||||
hourSize: hourSize,
|
|
||||||
minuteSize: minuteSize,
|
|
||||||
secondsSize: secondsSize,
|
|
||||||
ampmSize: ampmSize,
|
|
||||||
horizontalSepSize: horizontalSepSize,
|
horizontalSepSize: horizontalSepSize,
|
||||||
verticalSepSize: verticalSepSize,
|
verticalSepSize: verticalSepSize,
|
||||||
showSeconds: showSeconds,
|
showSeconds: showSeconds
|
||||||
showAMPM: showAMPM
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculate scale with maximum space utilization using full screen
|
// Calculate scale with maximum space utilization using full screen
|
||||||
@ -173,43 +134,28 @@ struct TimeDisplayView: View {
|
|||||||
// Time display with consistent centering and stable layout
|
// Time display with consistent centering and stable layout
|
||||||
Group {
|
Group {
|
||||||
if portrait {
|
if portrait {
|
||||||
VStack(spacing: 0) {
|
VStack(alignment: .center, spacing: 0) {
|
||||||
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
if showAMPM {
|
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
||||||
} else {
|
|
||||||
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
}
|
|
||||||
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
if showSeconds {
|
if showSeconds {
|
||||||
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
HorizontalColon(dotDiameter: dotDiameter, spacing: hSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
} else {
|
|
||||||
// Invisible placeholder to maintain consistent spacing
|
|
||||||
Spacer()
|
|
||||||
.frame(height: baseFontSize * 0.3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: baseFontSize * 0.035) {
|
HStack(alignment: .center, spacing: baseFontSize * 0.035) {
|
||||||
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
if showAMPM {
|
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
TimeSegment(text: ampmText, fontSize: ampmFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
|
||||||
} else {
|
|
||||||
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
}
|
|
||||||
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
if showSeconds {
|
if showSeconds {
|
||||||
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
VerticalColon(dotDiameter: dotDiameter, spacing: vSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign)
|
||||||
} else {
|
|
||||||
// Invisible placeholder to maintain consistent spacing
|
|
||||||
Spacer()
|
|
||||||
.frame(width: baseFontSize * 0.3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.border(Color.red, width: 3) // DEBUG: Red border to check positioning
|
||||||
.frame(width: fullScreenSize.width, height: fullScreenSize.height, alignment: .center)
|
.frame(width: fullScreenSize.width, height: fullScreenSize.height, alignment: .center)
|
||||||
.scaleEffect(finalScale, anchor: .center)
|
.scaleEffect(finalScale, anchor: .center)
|
||||||
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
|
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
|
||||||
@ -228,224 +174,37 @@ struct TimeDisplayView: View {
|
|||||||
|
|
||||||
private func calculateLayoutSize(
|
private func calculateLayoutSize(
|
||||||
portrait: Bool,
|
portrait: Bool,
|
||||||
hourSize: CGSize,
|
|
||||||
minuteSize: CGSize,
|
|
||||||
secondsSize: CGSize,
|
|
||||||
ampmSize: CGSize,
|
|
||||||
horizontalSepSize: CGSize,
|
horizontalSepSize: CGSize,
|
||||||
verticalSepSize: CGSize,
|
verticalSepSize: CGSize,
|
||||||
showSeconds: Bool,
|
showSeconds: Bool
|
||||||
showAMPM: Bool
|
|
||||||
) -> (CGFloat, CGFloat) {
|
) -> (CGFloat, CGFloat) {
|
||||||
|
// Simplified layout calculation without AM/PM
|
||||||
|
// This is just a placeholder since we're using natural sizing now
|
||||||
if portrait {
|
if portrait {
|
||||||
var widths: [CGFloat] = [hourSize.width, minuteSize.width]
|
let totalH = horizontalSepSize.height * (showSeconds ? 2 : 1)
|
||||||
var totalH: CGFloat = hourSize.height + minuteSize.height
|
return (0, totalH) // Width will be determined by content
|
||||||
|
|
||||||
if showAMPM {
|
|
||||||
widths.append(ampmSize.width)
|
|
||||||
totalH += ampmSize.height
|
|
||||||
} else {
|
|
||||||
widths.append(horizontalSepSize.width)
|
|
||||||
totalH += horizontalSepSize.height
|
|
||||||
}
|
|
||||||
|
|
||||||
if showSeconds {
|
|
||||||
widths.append(contentsOf: [horizontalSepSize.width, secondsSize.width])
|
|
||||||
totalH += horizontalSepSize.height + secondsSize.height
|
|
||||||
}
|
|
||||||
|
|
||||||
return (widths.max() ?? 0, totalH)
|
|
||||||
} else {
|
} else {
|
||||||
var totalW: CGFloat = hourSize.width + minuteSize.width
|
let totalW = verticalSepSize.width * (showSeconds ? 2 : 1)
|
||||||
var heights: [CGFloat] = [hourSize.height, minuteSize.height]
|
return (totalW, 0) // Height will be determined by content
|
||||||
|
|
||||||
if showAMPM {
|
|
||||||
totalW += ampmSize.width
|
|
||||||
heights.append(ampmSize.height)
|
|
||||||
} else {
|
|
||||||
totalW += verticalSepSize.width
|
|
||||||
heights.append(verticalSepSize.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if showSeconds {
|
|
||||||
totalW += verticalSepSize.width + secondsSize.width
|
|
||||||
heights.append(contentsOf: [verticalSepSize.height, secondsSize.height])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (totalW, heights.max() ?? 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Supporting Views
|
|
||||||
|
|
||||||
// Calculate width of text for the given font - this ensures consistent width
|
// MARK: - Preview
|
||||||
private func calculateMaxTextWidth(font: UIFont, text: String = "88") -> CGFloat {
|
#Preview {
|
||||||
let attributes = [NSAttributedString.Key.font: font]
|
let style = ClockStyle()
|
||||||
let size = (text as NSString).size(withAttributes: attributes)
|
return TimeDisplayView(
|
||||||
return size.width
|
date: Date(),
|
||||||
}
|
use24Hour: style.use24Hour,
|
||||||
|
showSeconds: style.showSeconds,
|
||||||
// Calculate height of text for the given font - this ensures consistent height
|
digitColor: style.digitColor,
|
||||||
private func calculateMaxTextHeight(font: UIFont, text: String = "8") -> CGFloat {
|
glowIntensity: style.glowIntensity,
|
||||||
let attributes = [NSAttributedString.Key.font: font]
|
manualScale: style.digitScale,
|
||||||
let size = (text as NSString).size(withAttributes: attributes)
|
stretched: style.stretched,
|
||||||
return size.height
|
clockOpacity: style.clockOpacity,
|
||||||
}
|
fontFamily: style.fontFamily,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
private struct TimeSegment: View {
|
fontDesign: style.fontDesign
|
||||||
let text: String
|
) .background(Color.black)
|
||||||
let fontSize: CGFloat
|
|
||||||
let opacity: Double
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let fontFamily: String
|
|
||||||
let fontWeight: String
|
|
||||||
let fontDesign: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let clamped = ColorUtils.clampOpacity(opacity)
|
|
||||||
let font = FontUtils.customUIFont(
|
|
||||||
size: fontSize,
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
design: fontDesign
|
|
||||||
)
|
|
||||||
let singleDigitWidth = calculateMaxTextWidth(font: font, text: "8")
|
|
||||||
let singleDigitHeight = calculateMaxTextHeight(font: font, text: "8")
|
|
||||||
let totalWidth = singleDigitWidth * CGFloat(text.count)
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
|
||||||
DigitView(
|
|
||||||
digit: String(character),
|
|
||||||
fontSize: fontSize,
|
|
||||||
opacity: clamped,
|
|
||||||
digitColor: digitColor,
|
|
||||||
glowIntensity: glowIntensity,
|
|
||||||
fontFamily: fontFamily,
|
|
||||||
fontWeight: fontWeight,
|
|
||||||
fontDesign: fontDesign,
|
|
||||||
digitWidth: singleDigitWidth,
|
|
||||||
digitHeight: singleDigitHeight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: totalWidth, alignment: .center)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate width of text for the given font - this ensures consistent width
|
|
||||||
private func calculateMaxTextWidth(font: UIFont, text: String = "88") -> CGFloat {
|
|
||||||
let attributes = [NSAttributedString.Key.font: font]
|
|
||||||
let size = (text as NSString).size(withAttributes: attributes)
|
|
||||||
return size.width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DigitView: View {
|
|
||||||
let digit: String
|
|
||||||
let fontSize: CGFloat
|
|
||||||
let opacity: Double
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let fontFamily: String
|
|
||||||
let fontWeight: String
|
|
||||||
let fontDesign: String
|
|
||||||
let digitWidth: CGFloat
|
|
||||||
let digitHeight: CGFloat
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Text(digit)
|
|
||||||
.font(FontUtils.customFont(
|
|
||||||
size: fontSize,
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
design: fontDesign
|
|
||||||
))
|
|
||||||
.foregroundColor(digitColor)
|
|
||||||
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
|
||||||
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Text(digit)
|
|
||||||
.font(FontUtils.customFont(
|
|
||||||
size: fontSize,
|
|
||||||
family: fontFamily,
|
|
||||||
weight: fontWeight,
|
|
||||||
design: fontDesign
|
|
||||||
))
|
|
||||||
.foregroundColor(digitColor)
|
|
||||||
.opacity(opacity)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(width: digitWidth, height: digitHeight, alignment: .center)
|
|
||||||
.fixedSize(horizontal: false, vertical: false)
|
|
||||||
.lineLimit(1)
|
|
||||||
.allowsTightening(false)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct HorizontalColon: View {
|
|
||||||
let dotDiameter: CGFloat
|
|
||||||
let spacing: CGFloat
|
|
||||||
let opacity: Double
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let fontWeight: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let clamped = ColorUtils.clampOpacity(opacity)
|
|
||||||
HStack(spacing: spacing) {
|
|
||||||
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct VerticalColon: View {
|
|
||||||
let dotDiameter: CGFloat
|
|
||||||
let spacing: CGFloat
|
|
||||||
let opacity: Double
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let fontWeight: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let clamped = ColorUtils.clampOpacity(opacity)
|
|
||||||
VStack(spacing: spacing) {
|
|
||||||
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: true)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DotCircle: View {
|
|
||||||
let size: CGFloat
|
|
||||||
let opacity: Double
|
|
||||||
let digitColor: Color
|
|
||||||
let glowIntensity: Double
|
|
||||||
let fontWeight: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// Calculate size based on font weight - make dots smaller for lighter weights
|
|
||||||
let sizeMultiplier = FontUtils.dotSizeMultiplier(for: fontWeight)
|
|
||||||
let adjustedSize = size * sizeMultiplier
|
|
||||||
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(digitColor)
|
|
||||||
.frame(width: adjustedSize, height: adjustedSize)
|
|
||||||
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
|
|
||||||
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * opacity)
|
|
||||||
Circle()
|
|
||||||
.fill(digitColor)
|
|
||||||
.frame(width: adjustedSize, height: adjustedSize)
|
|
||||||
.opacity(opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
94
TheNoiseClock/Views/Clock/Components/TimeSegment.swift
Normal file
94
TheNoiseClock/Views/Clock/Components/TimeSegment.swift
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// TimeSegment.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/9/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
|
||||||
|
struct TimeSegment: View {
|
||||||
|
let text: String
|
||||||
|
let fontSize: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let fontFamily: String
|
||||||
|
let fontWeight: String
|
||||||
|
let fontDesign: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 0) {
|
||||||
|
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
||||||
|
DigitView(
|
||||||
|
digit: String(character),
|
||||||
|
fontSize: fontSize,
|
||||||
|
opacity: clampedOpacity,
|
||||||
|
digitColor: digitColor,
|
||||||
|
glowIntensity: glowIntensity,
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
fontDesign: fontDesign,
|
||||||
|
digitWidth: singleDigitWidth,
|
||||||
|
digitHeight: singleDigitHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.border(Color.green, width: 2) // DEBUG: Green border around time segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
private var clampedOpacity: Double {
|
||||||
|
ColorUtils.clampOpacity(opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customFont: UIFont {
|
||||||
|
FontUtils.customUIFont(
|
||||||
|
size: fontSize,
|
||||||
|
family: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var singleDigitWidth: CGFloat {
|
||||||
|
calculateMaxTextWidth(font: customFont, text: "8")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var singleDigitHeight: CGFloat {
|
||||||
|
calculateMaxTextHeight(font: customFont, text: "8")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate width of text for the given font - this ensures consistent width
|
||||||
|
private func calculateMaxTextWidth(font: UIFont, text: String = "8") -> CGFloat {
|
||||||
|
let attributes = [NSAttributedString.Key.font: font]
|
||||||
|
let size = (text as NSString).size(withAttributes: attributes)
|
||||||
|
return size.width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate height of text for the given font - this ensures consistent height
|
||||||
|
private func calculateMaxTextHeight(font: UIFont, text: String = "8") -> CGFloat {
|
||||||
|
let attributes = [NSAttributedString.Key.font: font]
|
||||||
|
let size = (text as NSString).size(withAttributes: attributes)
|
||||||
|
return size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
let segment = TimeSegment(
|
||||||
|
text: "12",
|
||||||
|
fontSize: 80,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.2,
|
||||||
|
fontFamily: "System",
|
||||||
|
fontWeight: "Regular",
|
||||||
|
fontDesign: "Default"
|
||||||
|
)
|
||||||
|
|
||||||
|
return segment
|
||||||
|
.background(Color.black)
|
||||||
|
.frame(width: 200, height: 100)
|
||||||
|
}
|
||||||
41
TheNoiseClock/Views/Clock/Components/VerticalColon.swift
Normal file
41
TheNoiseClock/Views/Clock/Components/VerticalColon.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// VerticalColon.swift
|
||||||
|
// TheNoiseClock
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 9/9/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Component for displaying vertical colon separator (two dots stacked vertically)
|
||||||
|
struct VerticalColon: View {
|
||||||
|
let dotDiameter: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let digitColor: Color
|
||||||
|
let glowIntensity: Double
|
||||||
|
let fontWeight: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let clamped = ColorUtils.clampOpacity(opacity)
|
||||||
|
VStack(spacing: spacing) {
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
|
DotCircle(size: dotDiameter, opacity: clamped, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: true)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
VerticalColon(
|
||||||
|
dotDiameter: 12,
|
||||||
|
spacing: 8,
|
||||||
|
opacity: 1.0,
|
||||||
|
digitColor: .white,
|
||||||
|
glowIntensity: 0.3,
|
||||||
|
fontWeight: "Regular"
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user