Introduces a SwiftUI companion app for the Fail Well PWA: menu bar status item, popover with task list / record / history views, command palette, and a Dynamic Island-style notch overlay that shows the active step and timer beside the hardware notch and expands on hover to reveal pause/next controls.
257 lines
9.2 KiB
Swift
257 lines
9.2 KiB
Swift
import SwiftUI
|
|
|
|
struct NotchOverlayContent: View {
|
|
@Environment(AppStore.self) private var store
|
|
@Environment(Clock.self) private var clock
|
|
|
|
let state: NotchOverlayState
|
|
let geometry: NotchGeometry
|
|
|
|
var body: some View {
|
|
if 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)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
|
|
private func island(
|
|
active: (task: TaskPlan, record: TaskRecord),
|
|
step: Step,
|
|
range: TimeRange
|
|
) -> 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)
|
|
|
|
VStack(spacing: 0) {
|
|
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
|
|
)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|