Skip to content

Circular Time picker

Ravi Sawlani edited this page Jan 18, 2026 · 1 revision
//
//  CircularBedTimePicker.swift
//  ZzzMate
//
//  Created by Ravi on 20/10/25.
//

import SwiftUI


extension Date {
    var hourDecimal: Double {
        let components = Calendar.current.dateComponents([.hour, .minute, .second], from: self)
        let hour = Double(components.hour ?? 0)
        let minute = Double(components.minute ?? 0)
        let second = Double(components.second ?? 0)
        return hour + (minute / 60.0) + (second / 3600.0)
    }
}

struct CircularSleepPicker: View {
    var lengthOfArcInSecs: TimeInterval
    @Binding var wakeUpTime: Date
    
    
    var bedTimeHour: Double {
        let wakeUpHour = wakeUpTime.hourDecimal
        var bedTimeHour = wakeUpHour - (lengthOfArcInSecs / 3600)
        if bedTimeHour < 0 {
            bedTimeHour += 24.0
        }
        return bedTimeHour
    }
    

    var wakeUpHour: Double {
        get {
            wakeUpTime.hourDecimal
        }
        set {
            let hours = Int(newValue)
            let minutes = Int((newValue - Double(hours)) * 60)
            let seconds = Int((((newValue - Double(hours)) * 60) - Double(minutes)) * 60)
            let calendar = Calendar.current
            let now = Date()
            if let newDate = calendar.date(
                bySettingHour: hours,
                minute: minutes,
                second: seconds,
                of: now
            ) {
                wakeUpTime = newDate
            }
        }
    }
    

    @State private var dragStartAngle: Double? = nil
    @State private var dragStartHour: Double? = nil
    
    func setWakeUpTime(hour: Double) {
        let hours = Int(hour)
        let minutes = Int((hour - Double(hours)) * 60)
        let seconds = Int((((hour - Double(hours)) * 60) - Double(minutes)) * 60)
        let calendar = Calendar.current
        if hours < 0 || minutes < 0 || seconds < 0 {
            print("hour is \(hour), minutes is \(minutes), seconds is \(seconds)");
        }
        let now = Date()
        wakeUpTime = calendar.date(bySettingHour: hours, minute: minutes, second: seconds, of: now)!
    }

    var body: some View {
        GeometryReader { geo in
            // Sizing
            let diameter = min(geo.size.width, geo.size.height)
            let controllerRingStrokeBorder: CGFloat = diameter * 0.12
            let arcStrokeWidth: CGFloat = controllerRingStrokeBorder * 0.7
            let ringRadius: CGFloat = diameter / 2 - controllerRingStrokeBorder / 2
            let arcRadius: CGFloat = ringRadius

            let handleRadius: CGFloat = controllerRingStrokeBorder / 2 - 5
            let tickLengthMajor: CGFloat = diameter * 0.08
            let tickLengthMinor: CGFloat = diameter * 0.035
            let tickBaseRadius: CGFloat = ringRadius - controllerRingStrokeBorder/2
            let labelRadius: CGFloat = 0.75 * tickBaseRadius

            let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)

            // Fixed arc length in hours
            let arcLength: Double = {
                let length = lengthOfArcInSecs / 60 / 60
                return length
            }()

            // Drag gesture for whole arc
            let arcGesture = DragGesture()
                .onChanged { value in
                    let newAngle = angleFrom(center: center, point: value.location)
                    print("new angle \(newAngle)")
                    // while dragging the first time the value is set here
                    if dragStartAngle == nil || dragStartHour == nil {
                        dragStartAngle = newAngle
                        dragStartHour = bedTimeHour
                        return
                    }
                    let angleDelta = newAngle - (dragStartAngle ?? 0)
                    // Convert angle delta to hours
                    let hourDelta = angleDelta / 360 * 24
                    let newStart = (((dragStartHour ?? 0) + hourDelta).truncatingRemainder(dividingBy: 24))
                    let setHour = (newStart + arcLength).truncatingRemainder(dividingBy: 24)
                    print("hour delta \(hourDelta) new start \(newStart), new hour \(setHour) DragStartANgle \(dragStartAngle ?? 0)")
                    setWakeUpTime(hour: setHour)
                    
                }
                .onEnded { _ in
                    dragStartAngle = nil
                    dragStartHour = nil
                }

            ZStack {
                // Clock ring
                Circle()
                    .fill(.white)
                    .strokeBorder(Color(.systemGray4), lineWidth: controllerRingStrokeBorder)

                // Sleep range arc (fully inside via .strokeBorder)
                SleepArc(
                    startHour: bedTimeHour,
                    endHour: wakeUpHour,
                    radius: arcRadius
                )
                .stroke(Color.accentColor, lineWidth: arcStrokeWidth)
                .contentShape(Circle().inset(by: controllerRingStrokeBorder / 2))
                .gesture(arcGesture)
                // Animate on length changes if parent provides animations
                .animation(.easeInOut(duration: 0.35), value: wakeUpHour)

                // Tick marks
                ForEach(0..<24) { hour in
                    let angle = Angle(degrees: Double(hour) * 360 / 24 - 90)
                    let tickLength = hour % 6 == 0 ? tickLengthMajor : tickLengthMinor
                    Capsule()
                        .fill(Color.secondary.opacity(hour % 6 == 0 ? 0.7 : 0.3))
                        .frame(width: tickLength, height: 2)
                        .offset(y: -tickBaseRadius + tickLengthMinor / 2)
                        .rotationEffect(angle)
                }

                // Hour Labels (now INSIDE the gray circle)
                ForEach([0, 3, 6, 9, 12, 15, 18, 21], id: \.self) { hour in
                    let angle = Angle(degrees: Double(hour) * 360 / 24 - 90)
                    let label: String = {
                        if hour == 0 {
                            return "12AM"
                        } else if hour == 12 {
                            return "12PM"
                        } else if hour < 12 {
                            return "\(hour)AM"
                        } else {
                            return "\(hour - 12)PM"
                        }
                    }()
                    Text(label)
                        .font(.system(size: diameter * 0.052, weight: hour % 6 == 0 ? .bold : .semibold))
                        .foregroundColor(hour % 6 == 0 ? .text : .secondary)
                        .position(
                            x: center.x + cos(angle.radians) * labelRadius,
                            y: center.y + sin(angle.radians) * labelRadius
                        )
                }

                let startAngle = (bedTimeHour / 24) * 360 - 90
                let startHandlePos = polarPosition(angle: startAngle, radius: arcRadius, center: center)

                HandleView(size: handleRadius * 2)
                    .position(startHandlePos)
                    .animation(.easeInOut(duration: 0.35), value: startHandlePos)

                let endAngle = (wakeUpHour / 24) * 360 - 90
                let endHandlePos = polarPosition(angle: endAngle, radius: arcRadius, center: center)

                HandleView(icon: "alarm", size: handleRadius * 2)
                    .position(endHandlePos)
                    .animation(.easeInOut(duration: 0.35), value: endHandlePos)
            }
            .frame(width: geo.size.width, height: geo.size.height)
        }
        .aspectRatio(1, contentMode: .fit)
        .frame(minWidth: 240, minHeight: 240)
        .padding()
    }

    // Helper: Polar coordinates for handle/label
    func polarPosition(angle: Double, radius: CGFloat, center: CGPoint) -> CGPoint {
        let radians = Angle(degrees: angle).radians
        return CGPoint(
            x: center.x + cos(radians) * radius,
            y: center.y + sin(radians) * radius
        )
    }

    func angleFrom(center: CGPoint, point: CGPoint) -> Double {
        let dx = point.x - center.x
        let dy = point.y - center.y
        var angle = atan2(dy, dx) * 180 / .pi // degrees
        angle = angle < -90 ? angle + 450 : angle + 90
        return angle.truncatingRemainder(dividingBy: 360)
    }

    func normalizedHour(from angle: Double) -> Double {
        let hour = (angle / 360 * 24)
        let normalized = hour >= 0 ? hour : hour + 24
        return normalized.truncatingRemainder(dividingBy: 24)
    }
}

struct HandleView: View {
    var icon: String? = nil
    var size: CGFloat = 36
    var body: some View {
        ZStack {
            Circle()
                .fill(Color.white)
                .shadow(radius: size * 0.11)
                .frame(width: size, height: size)
            Circle()
                .stroke(Color.accentColor, lineWidth: size * 0.10)
                .frame(width: size, height: size)
            if let icon = icon {
                Image(systemName: icon).foregroundColor(.accentColor)
            } else {
                Image(systemName: "moon.fill").foregroundColor(.accentColor)
            }
        }
    }
}

// Arc segment for sleep range
struct SleepArc: Shape {
    var startHour: Double
    var endHour: Double
    var radius: CGFloat

    // Make the shape animatable by exposing the changing values
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(startHour, endHour) }
        set {
            startHour = newValue.first
            endHour = newValue.second
        }
    }

    func path(in rect: CGRect) -> Path {
        var p = Path()
        let startAngle = Angle(degrees: (startHour / 24) * 360 - 90)
        let endAngle = Angle(degrees: (endHour / 24) * 360 - 90)
        p.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                 radius: radius,
                 startAngle: startAngle,
                 endAngle: endAngle,
                 clockwise: false)
        return p
    }
}


#Preview {
    struct PreviewWrapper: View {
        @State private var wakeUpTime: Date = Date() // Current time, current time zone
        private var arcLength: TimeInterval = 8 * 60 * 60 // 12 hours
        
        var bedTime: Date {
            wakeUpTime - arcLength
        }

        var body: some View {
            let dateFormatter: DateFormatter = {
                let formatter = DateFormatter()
                formatter.dateFormat = "h:mm a" // e.g. "6:45 AM"
                return formatter
            }()

            VStack {
                CircularSleepPicker(lengthOfArcInSecs: arcLength, wakeUpTime: $wakeUpTime)
                Text("BedTime: \(dateFormatter.string(from: bedTime))")
                    .font(.headline)
                Text("Wake: \(dateFormatter.string(from: wakeUpTime))")
                    .font(.headline)
            }
            .padding()
        }
    }
    return PreviewWrapper()
}

Clone this wiki locally