remindwall / RWPureSwift / Sources / EditSettingsNew / TopLevel / ClockDialView.swift
Back to remindwall — Current RemindWall
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()
}