TheNoiseClock/TheNoiseClock/Features/Clock/Views/ClockView.swift

234 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}