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:
Julien Calixte
2026-05-15 11:27:51 +02:00
parent 0c0d555ac1
commit 29a71a73b2
33 changed files with 2602 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import SwiftUI
struct MenuBarPopover: View {
@Environment(AppStore.self) private var store
@Environment(Router.self) private var router
var body: some View {
@Bindable var router = router
ZStack {
VStack(spacing: 0) {
if let active = store.activeRecord, !isOnRecordScreen(active.task.id) {
ActiveRecordBanner(taskId: active.task.id)
Divider()
}
screen
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
if router.showPalette {
CommandPalette()
.transition(.opacity)
}
}
.onKeyPress(.init("k"), phases: .down) { event in
if event.modifiers.contains(.command) {
router.showPalette.toggle()
return .handled
}
return .ignored
}
.onKeyPress(.escape) {
if router.showPalette {
router.showPalette = false
return .handled
}
if router.stack.count > 1 {
router.pop()
return .handled
}
return .ignored
}
}
@ViewBuilder private var screen: some View {
switch router.current {
case .list:
TaskListView()
case .detail(let id):
TaskDetailView(taskId: id)
case .editor(let id):
TaskEditor(taskId: id)
case .record(let id):
RecordView(taskId: id)
case .history(let id):
TaskHistoryView(taskId: id)
case .settings:
SettingsView()
}
}
private func isOnRecordScreen(_ taskId: String) -> Bool {
if case .record(let id) = router.current, id == taskId { return true }
return false
}
}
private struct ActiveRecordBanner: View {
@Environment(AppStore.self) private var store
@Environment(Clock.self) private var clock
@Environment(Router.self) private var router
let taskId: String
var body: some View {
Button {
router.push(.record(taskId: taskId))
} label: {
HStack(spacing: 8) {
let isPaused = store.record(taskId: taskId)?.isPaused ?? false
Image(systemName: isPaused ? "pause.circle.fill" : "record.circle")
.foregroundStyle(isPaused ? Theme.flag : Theme.accent)
if let stepId = store.record(taskId: taskId)?.currentStepId,
let step = store.task(id: taskId)?.step(id: stepId),
let range = store.record(taskId: taskId)?.stepRecords[stepId]
{
Text(step.title).monoBody().lineLimit(1)
Spacer()
Text(TimeFormat.clock(clock.now.timeIntervalSince(range.start))).monoTimer()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}