feat(macos): add native menu bar app with notch overlay
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.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NotchOverlayState {
|
||||
var isExpanded = false
|
||||
}
|
||||
|
||||
struct NotchGeometry: Equatable {
|
||||
let screenFrame: NSRect
|
||||
let menuBarHeight: CGFloat
|
||||
let notchWidth: CGFloat
|
||||
}
|
||||
|
||||
enum NotchOverlayLayout {
|
||||
static let wingExtension: CGFloat = 96
|
||||
static let expandedBodyHeight: CGFloat = 132
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotchOverlayController {
|
||||
private let store: AppStore
|
||||
private let clock: Clock
|
||||
private let state = NotchOverlayState()
|
||||
private var panel: NSPanel?
|
||||
private var screenObserver: NSObjectProtocol?
|
||||
private var syncTask: Task<Void, Never>?
|
||||
|
||||
init(store: AppStore, clock: Clock) {
|
||||
self.store = store
|
||||
self.clock = clock
|
||||
|
||||
screenObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.rebuild()
|
||||
}
|
||||
}
|
||||
startSync()
|
||||
observeExpansion()
|
||||
}
|
||||
|
||||
private func startSync() {
|
||||
syncTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
self?.sync()
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observeExpansion() {
|
||||
withObservationTracking { [weak self] in
|
||||
_ = self?.state.isExpanded
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.updateFrame(animated: true)
|
||||
self?.observeExpansion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sync() {
|
||||
let shouldShow = store.activeRecord != nil && notchGeometry() != nil
|
||||
if shouldShow {
|
||||
ensurePanel()
|
||||
} else {
|
||||
tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
private func notchGeometry() -> NotchGeometry? {
|
||||
guard let screen = NSScreen.main, screen.safeAreaInsets.top > 0 else { return nil }
|
||||
let menuBarHeight = screen.safeAreaInsets.top
|
||||
let notchWidth: CGFloat
|
||||
if let left = screen.auxiliaryTopLeftArea, let right = screen.auxiliaryTopRightArea {
|
||||
notchWidth = max(0, screen.frame.width - left.width - right.width)
|
||||
} else {
|
||||
notchWidth = 200
|
||||
}
|
||||
return NotchGeometry(
|
||||
screenFrame: screen.frame,
|
||||
menuBarHeight: menuBarHeight,
|
||||
notchWidth: notchWidth
|
||||
)
|
||||
}
|
||||
|
||||
private func panelFrame(geometry: NotchGeometry, expanded: Bool) -> NSRect {
|
||||
let totalWidth = geometry.notchWidth + NotchOverlayLayout.wingExtension * 2
|
||||
let totalHeight = geometry.menuBarHeight + (expanded ? NotchOverlayLayout.expandedBodyHeight : 0)
|
||||
let x = geometry.screenFrame.origin.x + (geometry.screenFrame.width - totalWidth) / 2
|
||||
let y = geometry.screenFrame.origin.y + geometry.screenFrame.height - totalHeight
|
||||
return NSRect(x: x, y: y, width: totalWidth, height: totalHeight)
|
||||
}
|
||||
|
||||
private func ensurePanel() {
|
||||
guard let geometry = notchGeometry() else { return }
|
||||
if panel != nil { return }
|
||||
|
||||
let panel = NSPanel(
|
||||
contentRect: panelFrame(geometry: geometry, expanded: state.isExpanded),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.level = .screenSaver
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||
panel.ignoresMouseEvents = false
|
||||
panel.isMovable = false
|
||||
|
||||
let content = NotchOverlayContent(state: state, geometry: geometry)
|
||||
.environment(store)
|
||||
.environment(clock)
|
||||
let hosting = NSHostingController(rootView: content)
|
||||
hosting.view.wantsLayer = true
|
||||
hosting.view.layer?.backgroundColor = .clear
|
||||
panel.contentViewController = hosting
|
||||
panel.orderFront(nil)
|
||||
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func updateFrame(animated: Bool) {
|
||||
guard let panel, let geometry = notchGeometry() else { return }
|
||||
let newFrame = panelFrame(geometry: geometry, expanded: state.isExpanded)
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.32
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
ctx.allowsImplicitAnimation = true
|
||||
panel.animator().setFrame(newFrame, display: true)
|
||||
}
|
||||
} else {
|
||||
panel.setFrame(newFrame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDown() {
|
||||
state.isExpanded = false
|
||||
panel?.orderOut(nil)
|
||||
panel?.close()
|
||||
panel = nil
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
tearDown()
|
||||
sync()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user