Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / EditSettingsNew / TopLevel / ClockDialView.swift

Back to remindwall — Current RemindWall

Download this file

import SwiftUI

/// A circular clock dial for setting screen-off and screen-on times,
/// inspired by the iPhone Health app's Sleep Schedule interface.
struct ClockDialView: View {
    let startHour: Int      // 0-23
    let startMinute: Int    // 0-59
    let endHour: Int        // 0-23
    let endMinute: Int      // 0-59

    let onStartTimeChanged: (_ hour: Int, _ minute: Int) -> Void
    let onEndTimeChanged: (_ hour: Int, _ minute: Int) -> Void
    let onBothTimesChanged: (_ startHour: Int, _ startMinute: Int, _ endHour: Int, _ endMinute: Int) -> Void

    @State private var isDraggingStart = false
    @State private var isDraggingEnd = false
    @State private var isDraggingArc = false
    @State private var dragStartAngle: Double = 0
    @State private var originalStartTotalMinutes: Int = 0
    @State private var originalEndTotalMinutes: Int = 0
    @State private var previousStartAngle: Double = 0
    @State private var previousEndAngle: Double = 0

    private let trackWidth: CGFloat = 36

    var body: some View {
        GeometryReader { geometry in
            let size = min(geometry.size.width, geometry.size.height)
            let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2)
            let radius = size / 2 - 30

            ZStack {
                // Background track
                Circle()
                    .stroke(Color.gray.opacity(0.15), lineWidth: trackWidth)
                    .frame(width: radius * 2, height: radius * 2)
                    .position(center)

                // Hour tick marks
                ForEach(0..<12, id: \.self) { tick in
                    let angle = Double(tick) * 30.0
                    let isMajor = tick % 3 == 0
                    tickMark(angle: angle, radius: radius, center: center, isMajor: isMajor)
                }

                // Hour number labels
                ForEach(0..<12, id: \.self) { tick in
                    let hour = tick == 0 ? 12 : tick
                    let labelRadius = radius - trackWidth / 2 - 16
                    let radians = (Double(tick) * 30.0 - 90.0) * .pi / 180.0
                    Text("\(hour)")
                        .font(.system(size: 12, weight: .medium))
                        .foregroundStyle(.secondary)
                        .position(
                            x: center.x + labelRadius * cos(radians),
                            y: center.y + labelRadius * sin(radians)
                        )
                }

                // Colored arc between start and end
                ScreenOffArc(
                    startAngleDeg: timeToAngle(hour: startHour, minute: startMinute),
                    endAngleDeg: timeToAngle(hour: endHour, minute: endMinute)
                )
                .stroke(Color.indigo.opacity(0.35), style: StrokeStyle(lineWidth: trackWidth, lineCap: .round))
                .frame(width: radius * 2, height: radius * 2)
                .position(center)
                .contentShape(
                    ScreenOffArc(
                        startAngleDeg: timeToAngle(hour: startHour, minute: startMinute),
                        endAngleDeg: timeToAngle(hour: endHour, minute: endMinute)
                    )
                )
                .gesture(arcDragGesture(center: center))
                .accessibilityHidden(true)

                // Start handle (moon - screen off)
                handleView(
                    systemName: "moon.fill",
                    color: .indigo,
                    angle: timeToAngle(hour: startHour, minute: startMinute),
                    center: center,
                    radius: radius,
                    isDragging: isDraggingStart
                )
                .gesture(startHandleDragGesture(center: center))
                .accessibilityElement()
                .accessibilityLabel("Screen off time")
                .accessibilityValue(formatAccessibilityTime(hour: startHour, minute: startMinute))
                .accessibilityAdjustableAction { direction in
                    switch direction {
                    case .increment:
                        onStartTimeChanged((startHour + 1) % 24, startMinute)
                    case .decrement:
                        onStartTimeChanged((startHour - 1 + 24) % 24, startMinute)
                    @unknown default:
                        break
                    }
                }

                // End handle (sun - screen on)
                handleView(
                    systemName: "sun.max.fill",
                    color: .orange,
                    angle: timeToAngle(hour: endHour, minute: endMinute),
                    center: center,
                    radius: radius,
                    isDragging: isDraggingEnd
                )
                .gesture(endHandleDragGesture(center: center))
                .accessibilityElement()
                .accessibilityLabel("Screen on time")
                .accessibilityValue(formatAccessibilityTime(hour: endHour, minute: endMinute))
                .accessibilityAdjustableAction { direction in
                    switch direction {
                    case .increment:
                        onEndTimeChanged((endHour + 1) % 24, endMinute)
                    case .decrement:
                        onEndTimeChanged((endHour - 1 + 24) % 24, endMinute)
                    @unknown default:
                        break
                    }
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }

    // MARK: - Handle View

    @ViewBuilder
    private func handleView(
        systemName: String,
        color: Color,
        angle: Double,
        center: CGPoint,
        radius: CGFloat,
        isDragging: Bool
    ) -> some View {
        let radians = (angle - 90.0) * .pi / 180.0
        let x = center.x + radius * cos(radians)
        let y = center.y + radius * sin(radians)

        Circle()
            .fill(color)
            .frame(width: 44, height: 44)
            .overlay(
                Image(systemName: systemName)
                    .foregroundStyle(.white)
                    .font(.system(size: 18))
            )
            .shadow(color: color.opacity(0.3), radius: isDragging ? 8 : 4)
            .scaleEffect(isDragging ? 1.15 : 1.0)
            .position(x: x, y: y)
            .animation(.easeOut(duration: 0.15), value: isDragging)
    }

    // MARK: - Tick Marks

    @ViewBuilder
    private func tickMark(angle: Double, radius: CGFloat, center: CGPoint, isMajor: Bool) -> some View {
        let length: CGFloat = isMajor ? 8 : 4
        let radians = (angle - 90.0) * .pi / 180.0
        let outerRadius = radius + trackWidth / 2 - 2
        let innerRadius = outerRadius - length

        Path { path in
            path.move(to: CGPoint(
                x: center.x + innerRadius * cos(radians),
                y: center.y + innerRadius * sin(radians)
            ))
            path.addLine(to: CGPoint(
                x: center.x + outerRadius * cos(radians),
                y: center.y + outerRadius * sin(radians)
            ))
        }
        .stroke(Color.gray.opacity(0.4), lineWidth: isMajor ? 2 : 1)
    }

    // MARK: - Angle / Time Conversion

    /// Converts 24-hour time to angle on a 12-hour clock face.
    /// 0 degrees = 12 o'clock (top), increasing clockwise.
    private func timeToAngle(hour: Int, minute: Int) -> Double {
        let hour12 = Double(hour % 12)
        let minuteFraction = Double(minute) / 60.0
        return (hour12 + minuteFraction) * 30.0
    }

    /// Converts angle to 12-hour time, snapped to 5-minute increments.
    private func angleToTime(angle: Double) -> (hour12: Int, minute: Int) {
        let normalized = ((angle.truncatingRemainder(dividingBy: 360.0)) + 360.0)
            .truncatingRemainder(dividingBy: 360.0)
        let totalMinutes = normalized / 360.0 * 720.0
        let snapped = Int((totalMinutes / 5.0).rounded()) * 5
        let hour12 = (snapped / 60) % 12
        let minute = snapped % 60
        return (hour12, minute)
    }

    /// Converts a point to angle relative to center.
    /// 0 = 12 o'clock (top), increasing clockwise.
    private func pointToAngle(point: CGPoint, center: CGPoint) -> Double {
        let dx = point.x - center.x
        let dy = point.y - center.y
        let radians = atan2(dx, -dy)
        var degrees = radians * 180.0 / .pi
        if degrees < 0 { degrees += 360.0 }
        return degrees
    }

    /// Detects if dragging from one angle to another crosses the 12 o'clock position.
    private func crossesTwelve(from oldAngle: Double, to newAngle: Double) -> Bool {
        let threshold = 30.0
        let crossCW = oldAngle > (360.0 - threshold) && newAngle < threshold
        let crossCCW = oldAngle < threshold && newAngle > (360.0 - threshold)
        return crossCW || crossCCW
    }

    /// Adjusts a 24-hour value when dragging crosses the 12 o'clock boundary.
    private func adjustedHour24(currentHour24: Int, crossed: Bool, newHour12: Int) -> Int {
        var isAM = currentHour24 < 12
        if crossed { isAM.toggle() }
        if isAM {
            return newHour12 == 0 ? 0 : newHour12  // hour12 of 0 means 12 on the clock = 0 in 24h AM
        } else {
            return newHour12 == 0 ? 12 : newHour12 + 12  // hour12 of 0 means 12 on the clock = 12 in 24h PM
        }
    }

    private func formatAccessibilityTime(hour: Int, minute: Int) -> String {
        let h12 = hour % 12
        let displayHour = h12 == 0 ? 12 : h12
        let period = hour < 12 ? "AM" : "PM"
        return "\(displayHour) \(String(format: "%02d", minute)) \(period)"
    }

    // MARK: - Drag Gestures

    private func startHandleDragGesture(center: CGPoint) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                if !isDraggingStart {
                    isDraggingStart = true
                    previousStartAngle = timeToAngle(hour: startHour, minute: startMinute)
                }
                let angle = pointToAngle(point: value.location, center: center)
                let crossed = crossesTwelve(from: previousStartAngle, to: angle)
                let (hour12, minute) = angleToTime(angle: angle)
                let newHour24 = adjustedHour24(currentHour24: startHour, crossed: crossed, newHour12: hour12)
                previousStartAngle = angle
                onStartTimeChanged(newHour24, minute)
            }
            .onEnded { _ in
                isDraggingStart = false
            }
    }

    private func endHandleDragGesture(center: CGPoint) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                if !isDraggingEnd {
                    isDraggingEnd = true
                    previousEndAngle = timeToAngle(hour: endHour, minute: endMinute)
                }
                let angle = pointToAngle(point: value.location, center: center)
                let crossed = crossesTwelve(from: previousEndAngle, to: angle)
                let (hour12, minute) = angleToTime(angle: angle)
                let newHour24 = adjustedHour24(currentHour24: endHour, crossed: crossed, newHour12: hour12)
                previousEndAngle = angle
                onEndTimeChanged(newHour24, minute)
            }
            .onEnded { _ in
                isDraggingEnd = false
            }
    }

    private func arcDragGesture(center: CGPoint) -> some Gesture {
        DragGesture(minimumDistance: 5)
            .onChanged { value in
                if !isDraggingArc {
                    isDraggingArc = true
                    dragStartAngle = pointToAngle(point: value.startLocation, center: center)
                    originalStartTotalMinutes = startHour * 60 + startMinute
                    originalEndTotalMinutes = endHour * 60 + endMinute
                }
                let currentAngle = pointToAngle(point: value.location, center: center)
                var angleDelta = currentAngle - dragStartAngle
                // Handle wrapping around 360/0
                if angleDelta > 180 { angleDelta -= 360 }
                if angleDelta < -180 { angleDelta += 360 }
                let minuteDelta = Int((angleDelta / 360.0 * 720.0).rounded())
                let newStartTotal = ((originalStartTotalMinutes + minuteDelta) % 1440 + 1440) % 1440
                let newEndTotal = ((originalEndTotalMinutes + minuteDelta) % 1440 + 1440) % 1440
                // Snap to 5 minutes
                let snappedStart = (newStartTotal / 5) * 5
                let snappedEnd = (newEndTotal / 5) * 5
                onBothTimesChanged(
                    snappedStart / 60, snappedStart % 60,
                    snappedEnd / 60, snappedEnd % 60
                )
            }
            .onEnded { _ in
                isDraggingArc = false
            }
    }
}

// MARK: - Arc Shape

/// A shape that draws a clockwise arc from a start angle to an end angle
/// on a 12-hour clock face (0 degrees = 12 o'clock, clockwise).
struct ScreenOffArc: Shape {
    var startAngleDeg: Double
    var endAngleDeg: Double

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(startAngleDeg, endAngleDeg) }
        set { startAngleDeg = newValue.first; endAngleDeg = newValue.second }
    }

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        // Convert from "0=top, clockwise" to SwiftUI angles "0=right, clockwise"
        let start = Angle(degrees: startAngleDeg - 90.0)
        let end = Angle(degrees: endAngleDeg - 90.0)

        var path = Path()
        // clockwise: false in SwiftUI's flipped coordinate system draws clockwise visually
        path.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: false)
        return path
    }
}

// MARK: - Preview

#Preview("Clock Dial - Default Schedule") {
    ClockDialView(
        startHour: 22,
        startMinute: 0,
        endHour: 6,
        endMinute: 0,
        onStartTimeChanged: { _, _ in },
        onEndTimeChanged: { _, _ in },
        onBothTimesChanged: { _, _, _, _ in }
    )
    .frame(width: 320, height: 320)
    .padding()
}

#Preview("Clock Dial - Short Period") {
    ClockDialView(
        startHour: 23,
        startMinute: 30,
        endHour: 5,
        endMinute: 0,
        onStartTimeChanged: { _, _ in },
        onEndTimeChanged: { _, _ in },
        onBothTimesChanged: { _, _, _, _ in }
    )
    .frame(width: 320, height: 320)
    .padding()
}