Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
69793e85c5
commit
443f1e02ec
9
AGENTS.md
Normal file
9
AGENTS.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Use /ios-18-role
|
||||||
|
read the PRD.md
|
||||||
|
read the README.md
|
||||||
|
|
||||||
|
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||||
|
|
||||||
|
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
|
||||||
|
|
||||||
|
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
||||||
1
PRD.md
1
PRD.md
@ -136,6 +136,7 @@ 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
|
||||||
|
|||||||
@ -11,6 +11,9 @@ 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 idleTimer: Timer?
|
@State private var idleTimer: Timer?
|
||||||
@ -18,33 +21,62 @@ struct ClockView: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
GeometryReader { geometry in
|
||||||
viewModel.style.effectiveBackgroundColor
|
// When ignoring safe areas, geometry.size IS the full screen
|
||||||
.ignoresSafeArea()
|
let screenWidth = geometry.size.width
|
||||||
|
let screenHeight = geometry.size.height
|
||||||
|
let isLandscape = screenWidth > screenHeight
|
||||||
|
|
||||||
GeometryReader { geometry in
|
// Get safe area insets from UIWindow since GeometryReader ignores them
|
||||||
let isPhone = UIDevice.current.userInterfaceIdiom == .phone
|
let windowInsets = Self.getWindowSafeAreaInsets()
|
||||||
let isLandscape = geometry.size.width > geometry.size.height
|
let safeInsets = geometry.safeAreaInsets // May be 0 due to ignoresSafeArea
|
||||||
let islandPadding: CGFloat = isLandscape && isPhone ? 120 : 0
|
|
||||||
let safeInset = max(geometry.safeAreaInsets.leading, geometry.safeAreaInsets.trailing)
|
// Dynamic Island handling:
|
||||||
let symmetricInset = isLandscape ? max(safeInset, islandPadding) : 0
|
// In landscape, apply symmetric padding to keep content centered
|
||||||
|
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
|
||||||
|
let symmetricInset = isLandscape ? dynamicIslandInset : 0
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
// Main clock display container
|
// Background extends to full screen
|
||||||
ClockDisplayContainer(
|
viewModel.style.effectiveBackgroundColor
|
||||||
currentTime: viewModel.currentTime,
|
|
||||||
style: viewModel.style,
|
// Main clock display container with symmetric padding for Dynamic Island
|
||||||
isDisplayMode: viewModel.isDisplayMode
|
ClockDisplayContainer(
|
||||||
|
currentTime: viewModel.currentTime,
|
||||||
|
style: viewModel.style,
|
||||||
|
isDisplayMode: viewModel.isDisplayMode
|
||||||
|
)
|
||||||
|
.padding(.leading, symmetricInset)
|
||||||
|
.padding(.trailing, symmetricInset)
|
||||||
|
.debugBorder(Self.debugShowSafeAreas, color: .yellow, label: "ClockDisplayContainer")
|
||||||
|
|
||||||
|
// Top overlay container with symmetric padding
|
||||||
|
ClockOverlayContainer(style: viewModel.style)
|
||||||
|
.padding(.leading, symmetricInset)
|
||||||
|
.padding(.trailing, symmetricInset)
|
||||||
|
}
|
||||||
|
.frame(width: screenWidth, height: screenHeight)
|
||||||
|
.border(.purple, width: 3) // Debug: Full screen ZStack
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
if Self.debugShowSafeAreas {
|
||||||
|
safeAreaDebugInfo(
|
||||||
|
size: geometry.size,
|
||||||
|
windowInsets: windowInsets,
|
||||||
|
symmetricInset: symmetricInset
|
||||||
)
|
)
|
||||||
|
|
||||||
// Top overlay container
|
|
||||||
ClockOverlayContainer(style: viewModel.style)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, symmetricInset)
|
}
|
||||||
|
.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()
|
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.overlay {
|
.overlay {
|
||||||
@ -100,6 +132,51 @@ struct ClockView: View {
|
|||||||
}
|
}
|
||||||
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,39 +18,31 @@ struct ClockDisplayContainer: View {
|
|||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
var body: some View {
|
var body: some View {
|
||||||
return GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let isPortrait = geometry.size.height >= geometry.size.width
|
let isPortrait = geometry.size.height >= geometry.size.width
|
||||||
let hasOverlay = style.showBattery || style.showDate
|
let hasOverlay = style.showBattery || style.showDate
|
||||||
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
// Time display - fills all available space
|
||||||
// Top spacing to account for overlay
|
TimeDisplayView(
|
||||||
if hasOverlay {
|
date: currentTime,
|
||||||
Spacer()
|
use24Hour: style.use24Hour,
|
||||||
.frame(height: topSpacing)
|
showSeconds: style.showSeconds,
|
||||||
}
|
showAmPm: style.showAmPm,
|
||||||
|
digitColor: style.effectiveDigitColor,
|
||||||
// Time display - fills remaining space
|
glowIntensity: style.glowIntensity,
|
||||||
TimeDisplayView(
|
manualScale: style.digitScale,
|
||||||
date: currentTime,
|
stretched: style.stretched,
|
||||||
use24Hour: style.use24Hour,
|
clockOpacity: style.clockOpacity,
|
||||||
showSeconds: style.showSeconds,
|
fontFamily: style.fontFamily,
|
||||||
showAmPm: style.showAmPm,
|
fontWeight: style.fontWeight,
|
||||||
digitColor: style.effectiveDigitColor,
|
fontDesign: style.fontDesign,
|
||||||
glowIntensity: style.glowIntensity,
|
forceHorizontalMode: style.forceHorizontalMode,
|
||||||
manualScale: style.digitScale,
|
isDisplayMode: isDisplayMode
|
||||||
stretched: style.stretched,
|
)
|
||||||
clockOpacity: style.clockOpacity,
|
.padding(.top, topSpacing)
|
||||||
fontFamily: style.fontFamily,
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
fontWeight: style.fontWeight,
|
.transition(.opacity)
|
||||||
fontDesign: style.fontDesign,
|
|
||||||
forceHorizontalMode: style.forceHorizontalMode,
|
|
||||||
isDisplayMode: isDisplayMode
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
|
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Component for displaying a single digit with fixed width and glow effects
|
/// Component for displaying a single digit with fixed width and glow effects
|
||||||
struct DigitView: View {
|
struct DigitView: View {
|
||||||
@Environment(\.sizeCategory) private var sizeCategory
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
private static let debugShowBorders = false
|
||||||
|
|
||||||
let digit: String
|
let digit: String
|
||||||
let fontName: FontFamily
|
let fontName: FontFamily
|
||||||
@ -21,9 +24,6 @@ struct DigitView: View {
|
|||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
@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,
|
||||||
@ -45,14 +45,11 @@ struct DigitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
ZStack {
|
||||||
ZStack {
|
glowText
|
||||||
glowText
|
mainText
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
|
||||||
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,79 +61,26 @@ struct DigitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var glowText: some View {
|
private var glowText: some View {
|
||||||
text
|
baseText
|
||||||
.foregroundColor(digitColor)
|
.foregroundColor(digitColor)
|
||||||
.blur(radius: glowRadius)
|
.blur(radius: glowRadius)
|
||||||
.opacity(glowOpacity)
|
.opacity(glowOpacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainText: some View {
|
private var mainText: some View {
|
||||||
text
|
baseText
|
||||||
.foregroundColor(digitColor)
|
.foregroundColor(digitColor)
|
||||||
.opacity(opacity)
|
.opacity(opacity)
|
||||||
|
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var text: some View {
|
private var baseText: some View {
|
||||||
GeometryReader { geometry in
|
Text(digit)
|
||||||
Text(digit)
|
.font(FontUtils.createFont(name: fontName,
|
||||||
.font(FontUtils.createFont(name: fontName,
|
weight: weight,
|
||||||
weight: weight,
|
design: design,
|
||||||
design: design,
|
size: fontSize))
|
||||||
size: fontSize))
|
.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)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: geometry.size) { _, newSize in
|
|
||||||
calculateOptimalFontSize(for: newSize)
|
|
||||||
}
|
|
||||||
.onChange(of: sizeCategory) { _, _ in
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
.onChange(of: fontName) { _, _ in
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
.onChange(of: weight) { _, _ in
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
.onChange(of: design) { _, _ in
|
|
||||||
calculateOptimalFontSize(for: geometry.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func calculateOptimalFontSize(for size: CGSize) {
|
|
||||||
// Prevent multiple calculations for the same size
|
|
||||||
guard size != lastCalculatedSize && !isCalculating else { return }
|
|
||||||
|
|
||||||
// Prevent multiple updates per frame
|
|
||||||
guard !isCalculating else { return }
|
|
||||||
isCalculating = true
|
|
||||||
|
|
||||||
let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit,
|
|
||||||
fontName: fontName,
|
|
||||||
weight: weight,
|
|
||||||
design: design,
|
|
||||||
for: size,
|
|
||||||
isDisplayMode: false)
|
|
||||||
|
|
||||||
// Only update if the size is significantly different to prevent micro-adjustments
|
|
||||||
fontSize = optimalSize
|
|
||||||
lastCalculatedSize = size
|
|
||||||
|
|
||||||
// Reset calculation flag after a brief delay to allow for frame completion
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { // ~60fps
|
|
||||||
isCalculating = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,18 @@ import Bedrock
|
|||||||
/// Component for displaying segmented time with customizable formatting
|
/// Component for displaying segmented time with customizable formatting
|
||||||
struct TimeDisplayView: View {
|
struct TimeDisplayView: View {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
/// When true, shows static "88:88:88" to debug font sizing
|
||||||
|
private static let debugStaticTime = false
|
||||||
|
/// When true, shows debug overlays with sizing info
|
||||||
|
private static let debugShowOverlays = false
|
||||||
|
/// Static hour value for debug mode
|
||||||
|
private static let debugHour = "88"
|
||||||
|
/// Static minute value for debug mode
|
||||||
|
private static let debugMinute = "88"
|
||||||
|
/// Static seconds value for debug mode
|
||||||
|
private static let debugSeconds = "88"
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let date: Date
|
let date: Date
|
||||||
let use24Hour: Bool
|
let use24Hour: Bool
|
||||||
@ -29,6 +41,13 @@ struct TimeDisplayView: View {
|
|||||||
@State var fontSize: CGFloat = 100
|
@State var fontSize: CGFloat = 100
|
||||||
@State private var lastCalculatedContainerSize: CGSize = .zero
|
@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 = {
|
||||||
let df = DateFormatter()
|
let df = DateFormatter()
|
||||||
@ -72,19 +91,31 @@ struct TimeDisplayView: View {
|
|||||||
let portraitMode = containerSize.height >= containerSize.width
|
let portraitMode = containerSize.height >= containerSize.width
|
||||||
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
|
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
|
||||||
|
|
||||||
// Time components
|
// Time components - use debug static values if enabled
|
||||||
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
let hour: String
|
||||||
let minute = Self.minuteDF.string(from: date)
|
let minute: String
|
||||||
let secondsText = Self.secondDF.string(from: date)
|
let secondsText: String
|
||||||
|
|
||||||
|
if Self.debugStaticTime {
|
||||||
|
hour = Self.debugHour
|
||||||
|
minute = Self.debugMinute
|
||||||
|
secondsText = Self.debugSeconds
|
||||||
|
} else {
|
||||||
|
hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
|
||||||
|
minute = Self.minuteDF.string(from: date)
|
||||||
|
secondsText = Self.secondDF.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// AM/PM badge
|
// AM/PM badge - hide in debug mode
|
||||||
let shouldShowAmPm = showAmPm && !use24Hour
|
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
|
||||||
let amPmText = Self.amPmDF.string(from: date).uppercased()
|
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
|
||||||
|
|
||||||
// Separators - reasonable spacing with extra padding in landscape
|
// Separators - reasonable spacing with extra padding in landscape
|
||||||
let dotDiameter = fontSize * 0.75
|
let dotDiameter = fontSize * Layout.dotDiameterMultiplier
|
||||||
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
|
let dotSpacing = portrait
|
||||||
|
? fontSize * Layout.dotSpacingPortraitMultiplier
|
||||||
|
: fontSize * Layout.dotSpacingLandscapeMultiplier
|
||||||
|
|
||||||
// Simple scaling - let the content size naturally and apply manual scale
|
// Simple scaling - let the content size naturally and apply manual scale
|
||||||
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
|
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
|
||||||
@ -92,7 +123,7 @@ struct TimeDisplayView: View {
|
|||||||
// Time display with consistent centering and stable layout
|
// Time display with consistent centering and stable layout
|
||||||
return Group {
|
return Group {
|
||||||
if portrait {
|
if portrait {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center, spacing: 0) {
|
||||||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
|
||||||
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
@ -102,19 +133,32 @@ struct TimeDisplayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack(alignment: .center) {
|
// Landscape mode - use fixed digit widths for stable layout
|
||||||
|
// Each digit gets the same width (based on "8") so clock doesn't shift
|
||||||
|
let fixedDigitWidth = FontUtils.digitWidth(
|
||||||
|
fontName: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign,
|
||||||
|
fontSize: fontSize
|
||||||
|
)
|
||||||
|
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 0) {
|
||||||
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
|
||||||
|
.frame(width: segmentWidth, height: containerSize.height)
|
||||||
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, height: containerSize.height)
|
||||||
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)
|
||||||
|
.frame(width: segmentWidth, height: containerSize.height)
|
||||||
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, height: containerSize.height)
|
||||||
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)
|
||||||
|
.frame(width: segmentWidth, height: containerSize.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
if shouldShowAmPm {
|
if shouldShowAmPm {
|
||||||
Text(amPmText)
|
Text(amPmText)
|
||||||
@ -138,6 +182,12 @@ struct TimeDisplayView: View {
|
|||||||
.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 {
|
.onAppear {
|
||||||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
||||||
}
|
}
|
||||||
@ -157,36 +207,102 @@ struct TimeDisplayView: View {
|
|||||||
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
|
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
|
// MARK: - Font Sizing
|
||||||
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
|
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
|
||||||
guard containerSize != .zero else { return }
|
guard containerSize != .zero else { return }
|
||||||
if containerSize == lastCalculatedContainerSize {
|
|
||||||
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = portrait ? (showSeconds ? 5.0 : 3.0) : 1.0
|
var estimated = calculateFontSize(reservingColonSize: 0)
|
||||||
let digits = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
|
for _ in 0..<2 {
|
||||||
let digitSize = CGSize(
|
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||||||
width: max(1, containerSize.width / digits),
|
estimated = calculateFontSize(reservingColonSize: dotDiameter)
|
||||||
height: max(1, containerSize.height / rows)
|
}
|
||||||
)
|
|
||||||
|
if !portrait {
|
||||||
let estimated = FontUtils.calculateOptimalFontSize(
|
let dotDiameter = estimated * Layout.dotDiameterMultiplier
|
||||||
digit: "8",
|
let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
|
||||||
fontName: fontFamily,
|
let colonHeight = (dotDiameter * 2) + dotSpacing
|
||||||
weight: fontWeight,
|
if colonHeight > containerSize.height {
|
||||||
design: fontDesign,
|
estimated *= containerSize.height / colonHeight
|
||||||
for: digitSize,
|
}
|
||||||
isDisplayMode: false
|
}
|
||||||
)
|
|
||||||
|
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
|
||||||
|
|
||||||
if abs(estimated - fontSize) > 1 {
|
if abs(estimated - fontSize) > 1 {
|
||||||
fontSize = estimated
|
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
|
lastCalculatedContainerSize = containerSize
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,14 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
|
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
|
||||||
struct TimeSegment: View {
|
struct TimeSegment: View {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
private static let debugShowBorders = false
|
||||||
|
|
||||||
let text: String
|
let text: String
|
||||||
@Binding var fontSize: CGFloat
|
@Binding var fontSize: CGFloat
|
||||||
let opacity: Double
|
let opacity: Double
|
||||||
@ -21,7 +26,15 @@ struct TimeSegment: View {
|
|||||||
let isDisplayMode: Bool
|
let isDisplayMode: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
// Use fixed digit width based on widest digit ("8") for stable layout
|
||||||
|
let fixedDigitWidth = FontUtils.digitWidth(
|
||||||
|
fontName: fontFamily,
|
||||||
|
weight: fontWeight,
|
||||||
|
design: fontDesign,
|
||||||
|
fontSize: fontSize
|
||||||
|
)
|
||||||
|
|
||||||
|
return HStack(alignment: .center, spacing: 0) {
|
||||||
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
|
||||||
DigitView(
|
DigitView(
|
||||||
digit: String(character),
|
digit: String(character),
|
||||||
@ -34,11 +47,11 @@ struct TimeSegment: View {
|
|||||||
fontSize: $fontSize,
|
fontSize: $fontSize,
|
||||||
isDisplayMode: isDisplayMode
|
isDisplayMode: isDisplayMode
|
||||||
)
|
)
|
||||||
//.border(.red, width: 1)
|
.frame(width: fixedDigitWidth) // Fixed width for stability
|
||||||
|
.debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//.border(Color.green, width: 1)
|
.debugBorder(Self.debugShowBorders, color: .green, label: "Segment")
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|||||||
@ -31,14 +31,11 @@ enum FontFamily: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Font-specific size adjustment factor (1.0 = no adjustment)
|
||||||
|
/// Note: With CoreText glyph measurement, these are no longer needed
|
||||||
var percentageDownsize: CGFloat {
|
var percentageDownsize: CGFloat {
|
||||||
switch self {
|
// All fonts now use 1.0 since we have accurate glyph measurement
|
||||||
case .system:
|
return 1.0
|
||||||
return 0.95
|
|
||||||
case .georgia:
|
|
||||||
return 0.90
|
|
||||||
default: return 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var fontWeights: [Font.Weight] {
|
var fontWeights: [Font.Weight] {
|
||||||
|
|||||||
@ -8,10 +8,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CoreText
|
||||||
|
|
||||||
/// Font sizing and typography utilities
|
/// Font sizing and typography utilities
|
||||||
struct FontUtils {
|
struct FontUtils {
|
||||||
|
|
||||||
|
// MARK: - Debug Configuration
|
||||||
|
/// Safety margin to prevent clipping (1.0 = no margin, 0.98 = 2% margin)
|
||||||
|
private static let safetyMargin: CGFloat = 0.98
|
||||||
|
/// When true, logs font sizing calculations
|
||||||
|
private static let debugLogging = false
|
||||||
|
|
||||||
static func calculateOptimalFontSize(
|
static func calculateOptimalFontSize(
|
||||||
digit: String,
|
digit: String,
|
||||||
fontName: FontFamily,
|
fontName: FontFamily,
|
||||||
@ -23,7 +30,7 @@ struct FontUtils {
|
|||||||
var low: CGFloat = 1.0
|
var low: CGFloat = 1.0
|
||||||
var high: CGFloat = 2000.0
|
var high: CGFloat = 2000.0
|
||||||
|
|
||||||
while high - low > 0.01 {
|
while high - low > 0.5 {
|
||||||
let mid = (low + high) / 2
|
let mid = (low + high) / 2
|
||||||
let testFont = createUIFont(
|
let testFont = createUIFont(
|
||||||
name: fontName,
|
name: fontName,
|
||||||
@ -31,7 +38,7 @@ struct FontUtils {
|
|||||||
design: design,
|
design: design,
|
||||||
size: mid
|
size: mid
|
||||||
)
|
)
|
||||||
let textSize = tightBoundingBox(for: digit, withFont: testFont)
|
let textSize = glyphBoundingBox(for: digit, withFont: testFont)
|
||||||
|
|
||||||
if textSize.width <= size.width && textSize.height <= size.height {
|
if textSize.width <= size.width && textSize.height <= size.height {
|
||||||
low = mid
|
low = mid
|
||||||
@ -40,9 +47,60 @@ struct FontUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply more conservative sizing in full screen mode
|
// Apply minimal safety margin - removed excessive downsize factors
|
||||||
let baseSize = low * fontName.percentageDownsize
|
let finalSize = low * safetyMargin
|
||||||
return isDisplayMode ? baseSize * 0.95 : baseSize
|
|
||||||
|
if debugLogging {
|
||||||
|
let testFont = createUIFont(name: fontName, weight: weight, design: design, size: finalSize)
|
||||||
|
let glyphSize = glyphBoundingBox(for: digit, withFont: testFont)
|
||||||
|
print("[FontUtils] target=\(Int(size.width))×\(Int(size.height)) glyph=\(Int(glyphSize.width))×\(Int(glyphSize.height)) fontSize=\(Int(finalSize))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the fixed width for a single digit at the given font size
|
||||||
|
/// Uses "8" as the reference since it's typically the widest digit
|
||||||
|
static func digitWidth(
|
||||||
|
fontName: FontFamily,
|
||||||
|
weight: Font.Weight,
|
||||||
|
design: Font.Design,
|
||||||
|
fontSize: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
let font = createUIFont(name: fontName, weight: weight, design: design, size: fontSize)
|
||||||
|
return glyphBoundingBox(for: "8", withFont: font).width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the text size using typographic bounds that match SwiftUI rendering
|
||||||
|
private static func glyphBoundingBox(
|
||||||
|
for text: String,
|
||||||
|
withFont font: UIFont
|
||||||
|
) -> CGSize {
|
||||||
|
// Get the glyph advances (actual width of each character)
|
||||||
|
let ctFont = font as CTFont
|
||||||
|
var glyphs = [CGGlyph](repeating: 0, count: text.count)
|
||||||
|
var characters = [UniChar](text.utf16)
|
||||||
|
CTFontGetGlyphsForCharacters(ctFont, &characters, &glyphs, text.count)
|
||||||
|
|
||||||
|
var advances = [CGSize](repeating: .zero, count: glyphs.count)
|
||||||
|
CTFontGetAdvancesForGlyphs(ctFont, .horizontal, glyphs, &advances, glyphs.count)
|
||||||
|
|
||||||
|
let totalWidth = advances.reduce(0) { $0 + $1.width }
|
||||||
|
|
||||||
|
// Use ascender height for proper top clearance
|
||||||
|
// This matches what SwiftUI Text renders more closely
|
||||||
|
let ascender = font.ascender
|
||||||
|
let descender = abs(font.descender)
|
||||||
|
|
||||||
|
// For digits, we don't have descenders, but SwiftUI still allocates space
|
||||||
|
// Use ascender + small fraction of descender for safety
|
||||||
|
let height = ascender + (descender * 0.3)
|
||||||
|
|
||||||
|
if debugLogging {
|
||||||
|
print("[FontUtils] text='\(text)' width=\(Int(totalWidth)) ascender=\(Int(ascender)) height=\(Int(height))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGSize(width: ceil(totalWidth), height: ceil(height))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createFont(
|
static func createFont(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user