Files
failwell/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayContent.swift
Julien Calixte fe75df4e0e refactor(macos): route notch geometry through overlay state
Store NotchGeometry on NotchOverlayState so the SwiftUI content can read
it reactively instead of receiving it via initializer. Lets the
controller drive frame updates when geometry changes and simplifies the
panel resize to NSPanel's built-in animate flag.
2026-05-15 12:05:35 +02:00

258 lines
9.1 KiB
Swift

import SwiftUI
struct NotchOverlayContent: View {
@Environment(AppStore.self) private var store
@Environment(Clock.self) private var clock
let state: NotchOverlayState
var body: some View {
if let geometry = state.geometry,
let active = store.activeRecord,
let stepId = active.record.currentStepId,
let step = active.task.step(id: stepId),
let range = active.record.stepRecords[stepId]
{
island(active: active, step: step, range: range, geometry: geometry)
} else {
Color.clear
}
}
private func island(
active: (task: TaskPlan, record: TaskRecord),
step: Step,
range: TimeRange,
geometry: NotchGeometry
) -> some View {
let elapsed = clock.now.timeIntervalSince(range.start)
let estimationSec = Double(step.estimation) * 60
let remaining = max(0, estimationSec - elapsed)
let overshoot = max(0, elapsed - estimationSec)
let isPaused = active.record.isPaused
let isExpanded = state.isExpanded
let menuBarH = geometry.menuBarHeight
let notchW = geometry.notchWidth
return ZStack(alignment: .top) {
NotchShape(notchWidth: notchW, menuBarHeight: menuBarH)
.fill(Color.black, style: FillStyle(eoFill: true))
.shadow(color: .black.opacity(isExpanded ? 0.35 : 0), radius: 12, x: 0, y: 6)
wings(
step: step,
elapsed: elapsed,
remaining: remaining,
overshoot: overshoot,
isPaused: isPaused,
menuBarH: menuBarH,
notchW: notchW
)
if isExpanded {
expandedBody(
active: active,
step: step,
elapsed: elapsed,
remaining: remaining,
overshoot: overshoot,
isPaused: isPaused
)
.padding(.top, menuBarH)
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.animation(.spring(response: 0.32, dampingFraction: 0.82), value: isExpanded)
.onHover { hover in
state.isExpanded = hover
}
.onTapGesture(count: 2) {
NSApp.activate(ignoringOtherApps: true)
}
}
private func wings(
step: Step,
elapsed: TimeInterval,
remaining: TimeInterval,
overshoot: TimeInterval,
isPaused: Bool,
menuBarH: CGFloat,
notchW: CGFloat
) -> some View {
HStack(spacing: 0) {
// Left wing: status indicator + step title
HStack(spacing: 6) {
Image(systemName: isPaused ? "pause.circle.fill" : "record.circle")
.foregroundStyle(isPaused ? Theme.flag : Theme.accent)
.font(.system(size: 11))
Text(step.title)
.font(Theme.mono)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.trailing, 10)
.frame(maxWidth: .infinity, alignment: .trailing)
Color.clear.frame(width: notchW)
// Right wing: timer
HStack(spacing: 4) {
Text(TimeFormat.clock(elapsed))
.font(Theme.mono.monospacedDigit())
.foregroundStyle(.white)
if step.estimation > 0 {
if overshoot > 0 {
Text("+\(TimeFormat.clock(overshoot))")
.font(Theme.monoSmall)
.foregroundStyle(Theme.flag)
} else {
Text("·\(TimeFormat.clock(remaining))")
.font(Theme.monoSmall)
.foregroundStyle(Theme.dim)
}
}
}
.padding(.leading, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: menuBarH)
}
private func expandedBody(
active: (task: TaskPlan, record: TaskRecord),
step: Step,
elapsed: TimeInterval,
remaining: TimeInterval,
overshoot: TimeInterval,
isPaused: Bool
) -> some View {
VStack(spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(step.title)
.font(Theme.monoTitle)
.foregroundStyle(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 8)
if step.estimation > 0 {
Text("est \(step.estimation)m")
.font(Theme.monoSmall)
.foregroundStyle(Theme.dim)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.10))
.clipShape(Capsule())
}
}
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(TimeFormat.clock(elapsed))
.font(Theme.monoTimer)
.foregroundStyle(.white)
if step.estimation > 0 {
if overshoot > 0 {
Text("+\(TimeFormat.clock(overshoot))")
.font(Theme.mono)
.foregroundStyle(Theme.flag)
} else {
Text("/ \(TimeFormat.clock(remaining))")
.font(Theme.mono)
.foregroundStyle(Theme.dim)
}
}
Spacer()
}
HStack(spacing: 8) {
pillButton(
title: isPaused ? "Resume" : "Pause",
systemImage: isPaused ? "play.fill" : "pause.fill"
) {
isPaused
? store.resume(taskId: active.task.id)
: store.pause(taskId: active.task.id)
}
pillButton(title: "Next", systemImage: "forward.end.fill") {
store.nextStep(taskId: active.task.id)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.bottom, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func pillButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(Theme.mono)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
struct NotchShape: Shape {
let notchWidth: CGFloat
let menuBarHeight: CGFloat
func path(in rect: CGRect) -> Path {
let outerR: CGFloat = 14
let innerR: CGFloat = 8
let halfNotch = notchWidth / 2
let cx = rect.midX
let notchLeft = cx - halfNotch
let notchRight = cx + halfNotch
let cutoutBottom = rect.minY + menuBarHeight
let cutoutTop = rect.minY - 1
let bottomIsRounded = rect.height > menuBarHeight + outerR
var p = Path()
// Outer boundary: top flat, bottom rounded (when expanded)
p.move(to: CGPoint(x: rect.minX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
if bottomIsRounded {
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - outerR))
p.addQuadCurve(
to: CGPoint(x: rect.maxX - outerR, y: rect.maxY),
control: CGPoint(x: rect.maxX, y: rect.maxY)
)
p.addLine(to: CGPoint(x: rect.minX + outerR, y: rect.maxY))
p.addQuadCurve(
to: CGPoint(x: rect.minX, y: rect.maxY - outerR),
control: CGPoint(x: rect.minX, y: rect.maxY)
)
} else {
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
}
p.closeSubpath()
// Cutout: matches the hardware notch (rounded inner bottom corners)
p.move(to: CGPoint(x: notchLeft, y: cutoutTop))
p.addLine(to: CGPoint(x: notchLeft, y: cutoutBottom - innerR))
p.addQuadCurve(
to: CGPoint(x: notchLeft + innerR, y: cutoutBottom),
control: CGPoint(x: notchLeft, y: cutoutBottom)
)
p.addLine(to: CGPoint(x: notchRight - innerR, y: cutoutBottom))
p.addQuadCurve(
to: CGPoint(x: notchRight, y: cutoutBottom - innerR),
control: CGPoint(x: notchRight, y: cutoutBottom)
)
p.addLine(to: CGPoint(x: notchRight, y: cutoutTop))
p.closeSubpath()
return p
}
}