234 lines
9.5 KiB
Swift
234 lines
9.5 KiB
Swift
//
|
||
// ClockView.swift
|
||
// TheNoiseClock
|
||
//
|
||
// Created by Matt Bruce on 9/7/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
import Bedrock
|
||
|
||
/// Main clock display view with settings and display mode
|
||
struct ClockView: View {
|
||
|
||
// MARK: - Debug Configuration
|
||
private static let debugShowSafeAreas = false
|
||
|
||
// MARK: - Properties
|
||
@Bindable var viewModel: ClockViewModel
|
||
/// Whether this view is currently the selected tab - prevents race conditions on tab switch
|
||
let isOnClockTab: Bool
|
||
@State private var idleTimer: Timer?
|
||
@State private var didHandleTouch = false
|
||
@State private var isViewActive = false
|
||
|
||
/// Tab bar should ONLY be hidden when BOTH conditions are true:
|
||
/// 1. We're on the clock tab (prevents hiding when user switches away)
|
||
/// 2. Full-screen mode is active
|
||
private var shouldHideTabBar: Bool {
|
||
isOnClockTab && viewModel.isFullScreenMode
|
||
}
|
||
|
||
// MARK: - Body
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
// When ignoring safe areas, geometry.size IS the full screen
|
||
let screenWidth = geometry.size.width
|
||
let screenHeight = geometry.size.height
|
||
let isLandscape = screenWidth > screenHeight
|
||
|
||
// Get safe area insets from UIWindow since GeometryReader ignores them
|
||
let windowInsets = Self.getWindowSafeAreaInsets()
|
||
|
||
// Dynamic Island handling:
|
||
// In landscape, apply symmetric padding to keep content centered
|
||
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
|
||
let symmetricInset = isLandscape ? dynamicIslandInset : 0
|
||
|
||
ZStack {
|
||
// Background extends to full screen
|
||
viewModel.style.effectiveBackgroundColor
|
||
|
||
// Main clock display container with symmetric padding for Dynamic Island
|
||
ClockDisplayContainer(
|
||
currentTime: viewModel.currentTime,
|
||
style: viewModel.style,
|
||
isFullScreenMode: viewModel.isFullScreenMode
|
||
)
|
||
.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)
|
||
.overlay(alignment: .bottomLeading) {
|
||
if Self.debugShowSafeAreas {
|
||
safeAreaDebugInfo(
|
||
size: geometry.size,
|
||
windowInsets: windowInsets,
|
||
symmetricInset: 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() // Extend GeometryReader to full screen, we handle safe areas manually
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.statusBarHidden(true)
|
||
// Tab bar visibility controlled here but decision includes isOnClockTab from parent
|
||
// This prevents race conditions: when tab changes, isOnClockTab becomes false immediately
|
||
.toolbar(shouldHideTabBar ? .hidden : .visible, for: .tabBar)
|
||
.onChange(of: shouldHideTabBar) { oldValue, newValue in
|
||
Design.debugLog("[ClockView] shouldHideTabBar changed: \(oldValue) -> \(newValue) (isOnClockTab=\(isOnClockTab), isFullScreenMode=\(viewModel.isFullScreenMode))")
|
||
}
|
||
.simultaneousGesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { _ in
|
||
guard !didHandleTouch else { return }
|
||
didHandleTouch = true
|
||
handleUserInteraction()
|
||
}
|
||
.onEnded { _ in
|
||
didHandleTouch = false
|
||
}
|
||
)
|
||
.onAppear {
|
||
Design.debugLog("[ClockView] onAppear - setting isViewActive = true")
|
||
isViewActive = true
|
||
resetIdleTimer()
|
||
}
|
||
.onDisappear {
|
||
Design.debugLog("[ClockView] onDisappear - setting isViewActive = false, invalidating timer")
|
||
isViewActive = false
|
||
idleTimer?.invalidate()
|
||
idleTimer = nil
|
||
}
|
||
.onChange(of: viewModel.isFullScreenMode) { _, isFullScreenMode in
|
||
if isFullScreenMode {
|
||
idleTimer?.invalidate()
|
||
idleTimer = nil
|
||
} else {
|
||
resetIdleTimer()
|
||
}
|
||
}
|
||
.accessibilityIdentifier("clock.screen")
|
||
}
|
||
|
||
// MARK: - Idle Timer
|
||
private func resetIdleTimer() {
|
||
idleTimer?.invalidate()
|
||
idleTimer = nil
|
||
guard !viewModel.isFullScreenMode else { return }
|
||
idleTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
|
||
Task { @MainActor in
|
||
enterFullScreenFromIdle()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func enterFullScreenFromIdle() {
|
||
// Guard against entering full-screen if we're no longer on the clock tab
|
||
guard isViewActive else {
|
||
Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: view is not active (user switched tabs)")
|
||
return
|
||
}
|
||
guard !viewModel.isFullScreenMode else {
|
||
Design.debugLog("[ClockView] enterFullScreenFromIdle - BLOCKED: already in full-screen")
|
||
return
|
||
}
|
||
Design.debugLog("[ClockView] enterFullScreenFromIdle - entering full-screen")
|
||
viewModel.toggleFullScreenMode()
|
||
}
|
||
|
||
private func handleUserInteraction() {
|
||
if viewModel.isFullScreenMode {
|
||
viewModel.toggleFullScreenMode()
|
||
}
|
||
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(String(localized: "clock.debug.screen", defaultValue: "Screen: \(Int(size.width))×\(Int(size.height))"))
|
||
Text(
|
||
String(
|
||
localized: "clock.debug.window_insets",
|
||
defaultValue: "Window Insets: L:\(Int(windowInsets.left)) R:\(Int(windowInsets.right)) T:\(Int(windowInsets.top)) B:\(Int(windowInsets.bottom))"
|
||
)
|
||
)
|
||
Text(String(localized: "clock.debug.symmetric_inset", defaultValue: "Symmetric Inset: \(Int(symmetricInset))"))
|
||
Text(
|
||
String(
|
||
localized: "clock.debug.dynamic_island",
|
||
defaultValue: "Dynamic Island: \(hasDynamicIsland && isLandscape ? "Yes" : "No")"
|
||
)
|
||
)
|
||
Text(
|
||
String(
|
||
format: String(localized: "clock.debug.orientation", defaultValue: "Orientation: %1$@"),
|
||
locale: .current,
|
||
isLandscape
|
||
? String(localized: "clock.debug.orientation.landscape", defaultValue: "Landscape")
|
||
: String(localized: "clock.debug.orientation.portrait", defaultValue: "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
|
||
#Preview {
|
||
NavigationStack {
|
||
ClockView(viewModel: ClockViewModel(), isOnClockTab: true)
|
||
}
|
||
.frame(width: 400, height: 600)
|
||
.background(Color.black)
|
||
}
|