Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-09-08 11:07:05 -05:00
parent 2fafae909d
commit c8fa702382
5 changed files with 311 additions and 53 deletions

View File

@ -121,6 +121,147 @@ enum FontUtils {
return baseFontSize * 0.20
}
// MARK: - Font Customization
/// Convert font family string to Font
/// - Parameter family: Font family name
/// - Returns: SwiftUI Font
static func fontFamily(_ family: String) -> Font {
switch family {
case "San Francisco":
return .system(.body, design: .default)
case "System":
return .system(.body, design: .default)
case "Monaco":
return .system(.body, design: .monospaced)
case "Courier":
return .system(.body, design: .monospaced)
default:
return .system(.body, design: .default)
}
}
/// Convert font weight string to Font.Weight
/// - Parameter weight: Font weight name
/// - Returns: Font.Weight
static func fontWeight(_ weight: String) -> Font.Weight {
switch weight {
case "Ultra Light":
return .ultraLight
case "Thin":
return .thin
case "Light":
return .light
case "Regular":
return .regular
case "Medium":
return .medium
case "Semibold":
return .semibold
case "Bold":
return .bold
case "Heavy":
return .heavy
case "Black":
return .black
default:
return .bold
}
}
/// Convert font design string to Font.Design
/// - Parameter design: Font design name
/// - Returns: Font.Design
static func fontDesign(_ design: String) -> Font.Design {
switch design {
case "Default":
return .default
case "Serif":
return .serif
case "Rounded":
return .rounded
case "Monospaced":
return .monospaced
default:
return .rounded
}
}
/// Create a custom font with specified parameters
/// - Parameters:
/// - size: Font size
/// - family: Font family name
/// - weight: Font weight name
/// - design: Font design name
/// - Returns: SwiftUI Font
static func customFont(
size: CGFloat,
family: String,
weight: String,
design: String
) -> Font {
return .system(
size: size,
weight: fontWeight(weight),
design: fontDesign(design)
)
}
/// Create a UIFont with specified parameters for measurements
/// - Parameters:
/// - size: Font size
/// - family: Font family name
/// - weight: Font weight name
/// - design: Font design name
/// - Returns: UIFont
static func customUIFont(
size: CGFloat,
family: String,
weight: String,
design: String
) -> UIFont {
let uiWeight: UIFont.Weight
switch weight {
case "Ultra Light":
uiWeight = .ultraLight
case "Thin":
uiWeight = .thin
case "Light":
uiWeight = .light
case "Regular":
uiWeight = .regular
case "Medium":
uiWeight = .medium
case "Semibold":
uiWeight = .semibold
case "Bold":
uiWeight = .bold
case "Heavy":
uiWeight = .heavy
case "Black":
uiWeight = .black
default:
uiWeight = .bold
}
let uiDesign: UIFontDescriptor.SystemDesign
switch design {
case "Serif":
uiDesign = .serif
case "Rounded":
uiDesign = .rounded
case "Monospaced":
uiDesign = .monospaced
default:
uiDesign = .default
}
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
.withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), size: size)
}
/// Measure text size with given font
/// - Parameters:
/// - text: Text to measure

View File

@ -25,6 +25,11 @@ class ClockStyle: Codable, Equatable {
var stretched: Bool = true
var backgroundHex: String = AppConstants.Defaults.backgroundColorHex
// MARK: - Font Settings
var fontFamily: String = "System" // System, San Francisco, etc.
var fontWeight: String = "Bold" // Ultra Light, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
var fontDesign: String = "Rounded" // Default, Serif, Rounded, Monospaced
// MARK: - Overlay Settings
var showBattery: Bool = true
var showDate: Bool = true
@ -46,6 +51,9 @@ class ClockStyle: Codable, Equatable {
case digitScale
case stretched
case backgroundHex
case fontFamily
case fontWeight
case fontDesign
case showBattery
case showDate
case clockOpacity
@ -70,6 +78,9 @@ class ClockStyle: Codable, Equatable {
self.digitScale = try container.decodeIfPresent(Double.self, forKey: .digitScale) ?? self.digitScale
self.stretched = try container.decodeIfPresent(Bool.self, forKey: .stretched) ?? self.stretched
self.backgroundHex = try container.decodeIfPresent(String.self, forKey: .backgroundHex) ?? self.backgroundHex
self.fontFamily = try container.decodeIfPresent(String.self, forKey: .fontFamily) ?? self.fontFamily
self.fontWeight = try container.decodeIfPresent(String.self, forKey: .fontWeight) ?? self.fontWeight
self.fontDesign = try container.decodeIfPresent(String.self, forKey: .fontDesign) ?? self.fontDesign
self.showBattery = try container.decodeIfPresent(Bool.self, forKey: .showBattery) ?? self.showBattery
self.showDate = try container.decodeIfPresent(Bool.self, forKey: .showDate) ?? self.showDate
self.clockOpacity = try container.decodeIfPresent(Double.self, forKey: .clockOpacity) ?? self.clockOpacity
@ -89,6 +100,9 @@ class ClockStyle: Codable, Equatable {
try container.encode(digitScale, forKey: .digitScale)
try container.encode(stretched, forKey: .stretched)
try container.encode(backgroundHex, forKey: .backgroundHex)
try container.encode(fontFamily, forKey: .fontFamily)
try container.encode(fontWeight, forKey: .fontWeight)
try container.encode(fontDesign, forKey: .fontDesign)
try container.encode(showBattery, forKey: .showBattery)
try container.encode(showDate, forKey: .showDate)
try container.encode(clockOpacity, forKey: .clockOpacity)
@ -131,6 +145,9 @@ class ClockStyle: Codable, Equatable {
lhs.digitScale == rhs.digitScale &&
lhs.stretched == rhs.stretched &&
lhs.backgroundHex == rhs.backgroundHex &&
lhs.fontFamily == rhs.fontFamily &&
lhs.fontWeight == rhs.fontWeight &&
lhs.fontDesign == rhs.fontDesign &&
lhs.showBattery == rhs.showBattery &&
lhs.showDate == rhs.showDate &&
lhs.clockOpacity == rhs.clockOpacity &&

View File

@ -28,6 +28,7 @@ struct ClockSettingsView: View {
NavigationView {
Form {
TimeFormatSection(style: $style)
FontSection(style: $style)
AppearanceSection(
style: $style,
digitColor: $digitColor,
@ -56,6 +57,57 @@ struct ClockSettingsView: View {
}
// MARK: - Supporting Views
private struct FontSection: View {
@Binding var style: ClockStyle
private let fontFamilies = ["System", "San Francisco", "Monaco", "Courier"]
private let fontWeights = ["Ultra Light", "Thin", "Light", "Regular", "Medium", "Semibold", "Bold", "Heavy", "Black"]
private let fontDesigns = ["Default", "Serif", "Rounded", "Monospaced"]
var body: some View {
Section(header: Text("Font")) {
// Font Family
Picker("Family", selection: $style.fontFamily) {
ForEach(fontFamilies, id: \.self) { family in
Text(family).tag(family)
}
}
.pickerStyle(.menu)
// Font Weight
Picker("Weight", selection: $style.fontWeight) {
ForEach(fontWeights, id: \.self) { weight in
Text(weight).tag(weight)
}
}
.pickerStyle(.menu)
// Font Design
Picker("Design", selection: $style.fontDesign) {
ForEach(fontDesigns, id: \.self) { design in
Text(design).tag(design)
}
}
.pickerStyle(.menu)
// Font Preview
HStack {
Text("Preview:")
.foregroundColor(.secondary)
Spacer()
Text("12:34")
.font(FontUtils.customFont(
size: 24,
family: style.fontFamily,
weight: style.fontWeight,
design: style.fontDesign
))
.foregroundColor(.primary)
}
}
}
}
private struct TimeFormatSection: View {
@Binding var style: ClockStyle

View File

@ -20,41 +20,45 @@ struct ClockView: View {
viewModel.style.backgroundColor
.ignoresSafeArea()
VStack(spacing: UIConstants.Spacing.medium) {
// Top overlay
if viewModel.style.showBattery || viewModel.style.showDate {
TopOverlayView(
showBattery: viewModel.style.showBattery,
showDate: viewModel.style.showDate,
color: viewModel.style.digitColor.opacity(UIConstants.Opacity.primary),
opacity: viewModel.style.overlayOpacity
GeometryReader { geometry in
ZStack {
// Time display - fills available space within safe areas
TimeDisplayView(
date: viewModel.currentTime,
use24Hour: viewModel.style.use24Hour,
showSeconds: viewModel.style.showSeconds,
digitColor: viewModel.style.digitColor,
glowIntensity: viewModel.style.glowIntensity,
manualScale: viewModel.style.digitScale,
stretched: viewModel.style.stretched,
showAmPmBadge: viewModel.style.showAmPmBadge,
clockOpacity: viewModel.style.clockOpacity,
fontFamily: viewModel.style.fontFamily,
fontWeight: viewModel.style.fontWeight,
fontDesign: viewModel.style.fontDesign
)
.padding(.top, UIConstants.Spacing.small)
.padding(.horizontal, UIConstants.Spacing.large)
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity)
// Top overlay - positioned at top with safe area consideration
VStack {
if viewModel.style.showBattery || viewModel.style.showDate {
TopOverlayView(
showBattery: viewModel.style.showBattery,
showDate: viewModel.style.showDate,
color: viewModel.style.digitColor.opacity(UIConstants.Opacity.primary),
opacity: viewModel.style.overlayOpacity
)
.padding(.top, UIConstants.Spacing.small)
.padding(.horizontal, UIConstants.Spacing.large)
.transition(.opacity)
}
Spacer()
}
}
Spacer()
// Time display
TimeDisplayView(
date: viewModel.currentTime,
use24Hour: viewModel.style.use24Hour,
showSeconds: viewModel.style.showSeconds,
digitColor: viewModel.style.digitColor,
glowIntensity: viewModel.style.glowIntensity,
manualScale: viewModel.style.digitScale,
stretched: viewModel.style.stretched,
showAmPmBadge: viewModel.style.showAmPmBadge,
clockOpacity: viewModel.style.clockOpacity
)
.padding(.horizontal, UIConstants.Spacing.medium)
.transition(.opacity)
Spacer()
.scaleEffect(viewModel.isDisplayMode ? 1.0 : 0.995)
.animation(UIConstants.AnimationCurves.smooth, value: viewModel.isDisplayMode)
}
.scaleEffect(viewModel.isDisplayMode ? 1.0 : 0.995)
.animation(UIConstants.AnimationCurves.smooth, value: viewModel.isDisplayMode)
}
.navigationTitle(viewModel.isDisplayMode ? "" : "Clock")
.toolbar {
@ -72,6 +76,7 @@ struct ClockView: View {
}
.navigationBarBackButtonHidden(viewModel.isDisplayMode)
.toolbar(viewModel.isDisplayMode ? .hidden : .automatic)
.statusBarHidden(viewModel.isDisplayMode)
.onAppear {
setTabBarHidden(viewModel.isDisplayMode, animated: false)
}
@ -90,6 +95,7 @@ struct ClockView: View {
.onEnded { _ in
viewModel.toggleDisplayMode()
setTabBarHidden(viewModel.isDisplayMode, animated: true)
// Status bar hiding is handled by the .statusBarHidden modifier
}
)
}

View File

@ -20,6 +20,9 @@ struct TimeDisplayView: View {
let stretched: Bool
let showAmPmBadge: Bool
let clockOpacity: Double
let fontFamily: String
let fontWeight: String
let fontDesign: String
// MARK: - Formatters
private static let hour24DF: DateFormatter = {
@ -63,18 +66,29 @@ struct TimeDisplayView: View {
let size = proxy.size
let portrait = size.height >= size.width
// Use optimal font sizing that maximizes space usage
// Get safe area information to avoid Dynamic Island overlap
let safeAreaInsets = proxy.safeAreaInsets
let availableWidth = size.width - safeAreaInsets.leading - safeAreaInsets.trailing
let availableHeight = size.height - safeAreaInsets.top - safeAreaInsets.bottom
// Use available space within safe areas for font sizing calculations
let availableSize = CGSize(width: availableWidth, height: availableHeight)
// Use full GeometryReader size for frame (to fill entire space)
let fullScreenSize = size
// Use optimal font sizing that maximizes space usage within safe areas
let baseFontSize = stretched ?
FontUtils.maximumStretchedFontSize(
containerWidth: size.width,
containerHeight: size.height,
containerWidth: availableSize.width,
containerHeight: availableSize.height,
isPortrait: portrait,
showSeconds: showSeconds,
showAmPm: !use24Hour && showAmPmBadge
) :
FontUtils.optimalFontSize(
containerWidth: size.width,
containerHeight: size.height,
containerWidth: availableSize.width,
containerHeight: availableSize.height,
isPortrait: portrait,
showSeconds: showSeconds,
showAmPm: !use24Hour && showAmPmBadge
@ -89,8 +103,18 @@ struct TimeDisplayView: View {
let showAMPM = !use24Hour && showAmPmBadge
// Calculate sizes using fixed-width approach to prevent jumping
let digitUIFont = UIFont.systemFont(ofSize: baseFontSize, weight: .bold)
let ampmUIFont = UIFont.systemFont(ofSize: ampmFontSize, weight: .bold)
let digitUIFont = FontUtils.customUIFont(
size: baseFontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
)
let ampmUIFont = FontUtils.customUIFont(
size: ampmFontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
)
// Use fixed-width calculations to prevent layout jumping
let digitWidth = FontUtils.maxDigitWidth(font: digitUIFont)
@ -122,10 +146,10 @@ struct TimeDisplayView: View {
showAMPM: showAMPM
)
// Calculate scale with maximum space utilization
// Calculate scale with maximum space utilization using available space
let safeInset = AppConstants.Defaults.safeInset
let availableW = max(1, size.width - safeInset * 2)
let availableH = max(1, size.height - safeInset * 2)
let availableW = max(1, availableSize.width - safeInset * 2)
let availableH = max(1, availableSize.height - safeInset * 2)
// Calculate scaling factors
let widthScale = availableW / max(totalWidth, 1)
@ -142,16 +166,16 @@ struct TimeDisplayView: View {
Group {
if portrait {
VStack(spacing: 0) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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)
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)
}
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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)
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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()
@ -160,16 +184,16 @@ struct TimeDisplayView: View {
}
} else {
HStack(spacing: 0) {
TimeSegment(text: hour, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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)
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)
}
TimeSegment(text: minute, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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)
TimeSegment(text: secondsText, fontSize: baseFontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity)
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()
@ -178,7 +202,7 @@ struct TimeDisplayView: View {
}
}
}
.frame(width: size.width, height: size.height, alignment: .center)
.frame(width: fullScreenSize.width, height: fullScreenSize.height, alignment: .center)
.scaleEffect(finalScale, anchor: .center)
.animation(UIConstants.AnimationCurves.smooth, value: finalScale)
.animation(UIConstants.AnimationCurves.smooth, value: showSeconds) // Smooth animation for seconds toggle
@ -258,20 +282,38 @@ private struct TimeSegment: View {
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 = UIFont.systemFont(ofSize: fontSize, weight: .bold)
let font = FontUtils.customUIFont(
size: fontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
)
let maxWidth = FontUtils.maxDigitWidth(font: font)
ZStack {
Text(text)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.font(FontUtils.customFont(
size: fontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
))
.foregroundColor(digitColor)
.blur(radius: ColorUtils.glowRadius(intensity: glowIntensity))
.opacity(ColorUtils.glowOpacity(intensity: glowIntensity) * clamped)
Text(text)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.font(FontUtils.customFont(
size: fontSize,
family: fontFamily,
weight: fontWeight,
design: fontDesign
))
.foregroundColor(digitColor)
.opacity(clamped)
}