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
|
||||
- **Display mode**: Full-screen clock activated by long-press (0.6 seconds)
|
||||
- **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
|
||||
- **Status bar control**: Status bar automatically hidden in full-screen mode
|
||||
- **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
|
||||
- **Real-time updates**: Changes apply immediately
|
||||
- **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
|
||||
|
||||
@ -576,6 +580,62 @@ The following changes **automatically require** PRD updates:
|
||||
- **Weather integration**: Weather-based alarm sounds
|
||||
- **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
|
||||
|
||||
### Project Information
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>TheNoiseClock.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -34,28 +34,6 @@ extension View {
|
||||
.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
|
||||
/// - Parameters:
|
||||
@ -155,22 +133,3 @@ struct OrientationChangeModifier: ViewModifier {
|
||||
.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
|
||||
viewModel.updateStyle(newStyle)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents(UIDevice.current.userInterfaceIdiom == .pad ? [.large] : [.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackgroundInteraction(.enabled)
|
||||
}
|
||||
.overlay {
|
||||
// Toolbar overlay
|
||||
@ -51,7 +53,7 @@ struct ClockView: View {
|
||||
}
|
||||
.overlay {
|
||||
// Tab bar management overlay
|
||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode, animated: true)
|
||||
ClockTabBarManager(isDisplayMode: viewModel.isDisplayMode)
|
||||
}
|
||||
.overlay {
|
||||
// Gesture handling overlay
|
||||
@ -67,4 +69,6 @@ struct ClockView: View {
|
||||
NavigationStack {
|
||||
ClockView()
|
||||
}
|
||||
.frame(width: 400, height: 600)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
@ -60,4 +60,6 @@ struct ClockDisplayContainer: View {
|
||||
style: ClockStyle(),
|
||||
isDisplayMode: false
|
||||
)
|
||||
.frame(width: 400, height: 600)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
@ -8,48 +8,20 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Component that manages tab bar visibility for display mode
|
||||
/// Uses SwiftUI's native toolbar hiding for proper iPad compatibility
|
||||
struct ClockTabBarManager: View {
|
||||
|
||||
// MARK: - Properties
|
||||
let isDisplayMode: Bool
|
||||
let animated: Bool
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
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
|
||||
.toolbar(isDisplayMode ? .hidden : .automatic, for: .tabBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 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
|
||||
}()
|
||||
|
||||
private static let ampmDF: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
df.dateFormat = "a"
|
||||
return df
|
||||
}()
|
||||
|
||||
// MARK: - Body
|
||||
var body: some View {
|
||||
@ -89,14 +83,11 @@ struct TimeDisplayView: View {
|
||||
showSeconds: showSeconds,
|
||||
showAmPm: false
|
||||
)
|
||||
let ampmFontSize = FontUtils.ampmFontSize(baseFontSize: baseFontSize)
|
||||
|
||||
// Time components
|
||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||||
let minute = Self.minuteDF.string(from: date)
|
||||
let secondsText = Self.secondDF.string(from: date)
|
||||
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
|
||||
let digitUIFont = FontUtils.customUIFont(
|
||||
@ -105,35 +96,10 @@ struct TimeDisplayView: View {
|
||||
weight: fontWeight,
|
||||
design: fontDesign
|
||||
)
|
||||
let ampmUIFont = FontUtils.customUIFont(
|
||||
size: ampmFontSize,
|
||||
family: fontFamily,
|
||||
weight: fontWeight,
|
||||
design: fontDesign
|
||||
)
|
||||
|
||||
// Calculate consistent sizes for layout
|
||||
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
|
||||
let dotDiameter = baseFontSize * 0.20
|
||||
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 verticalSepSize = CGSize(width: dotDiameter, height: dotDiameter * 2 + vSpacing)
|
||||
|
||||
// Calculate layout
|
||||
// Calculate layout - simplified without AM/PM
|
||||
let (totalWidth, totalHeight) = calculateLayoutSize(
|
||||
portrait: portrait,
|
||||
hourSize: hourSize,
|
||||
minuteSize: minuteSize,
|
||||
secondsSize: secondsSize,
|
||||
ampmSize: ampmSize,
|
||||
horizontalSepSize: horizontalSepSize,
|
||||
verticalSepSize: verticalSepSize,
|
||||
showSeconds: showSeconds,
|
||||
showAMPM: showAMPM
|
||||
showSeconds: showSeconds
|
||||
)
|
||||
|
||||
// Calculate scale with maximum space utilization using full screen
|
||||
@ -173,43 +134,28 @@ struct TimeDisplayView: View {
|
||||
// Time display with consistent centering and stable layout
|
||||
Group {
|
||||
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)
|
||||
if showAMPM {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
if showSeconds {
|
||||
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)
|
||||
} else {
|
||||
// Invisible placeholder to maintain consistent spacing
|
||||
Spacer()
|
||||
.frame(height: baseFontSize * 0.3)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
if showAMPM {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
if showSeconds {
|
||||
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)
|
||||
} 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)
|
||||
.scaleEffect(finalScale, anchor: .center)
|
||||
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
|
||||
@ -228,224 +174,37 @@ struct TimeDisplayView: View {
|
||||
|
||||
private func calculateLayoutSize(
|
||||
portrait: Bool,
|
||||
hourSize: CGSize,
|
||||
minuteSize: CGSize,
|
||||
secondsSize: CGSize,
|
||||
ampmSize: CGSize,
|
||||
horizontalSepSize: CGSize,
|
||||
verticalSepSize: CGSize,
|
||||
showSeconds: Bool,
|
||||
showAMPM: Bool
|
||||
showSeconds: Bool
|
||||
) -> (CGFloat, CGFloat) {
|
||||
// Simplified layout calculation without AM/PM
|
||||
// This is just a placeholder since we're using natural sizing now
|
||||
if portrait {
|
||||
var widths: [CGFloat] = [hourSize.width, minuteSize.width]
|
||||
var totalH: CGFloat = hourSize.height + minuteSize.height
|
||||
|
||||
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)
|
||||
let totalH = horizontalSepSize.height * (showSeconds ? 2 : 1)
|
||||
return (0, totalH) // Width will be determined by content
|
||||
} else {
|
||||
var totalW: CGFloat = hourSize.width + minuteSize.width
|
||||
var heights: [CGFloat] = [hourSize.height, minuteSize.height]
|
||||
|
||||
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)
|
||||
let totalW = verticalSepSize.width * (showSeconds ? 2 : 1)
|
||||
return (totalW, 0) // Height will be determined by content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
private 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
let style = ClockStyle()
|
||||
return TimeDisplayView(
|
||||
date: Date(),
|
||||
use24Hour: style.use24Hour,
|
||||
showSeconds: style.showSeconds,
|
||||
digitColor: style.digitColor,
|
||||
glowIntensity: style.glowIntensity,
|
||||
manualScale: style.digitScale,
|
||||
stretched: style.stretched,
|
||||
clockOpacity: style.clockOpacity,
|
||||
fontFamily: style.fontFamily,
|
||||
fontWeight: style.fontWeight,
|
||||
fontDesign: style.fontDesign
|
||||
) .background(Color.black)
|
||||
}
|
||||
|
||||
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