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:
132
macos/Sources/FailWell/Views/TaskDetailView.swift
Normal file
132
macos/Sources/FailWell/Views/TaskDetailView.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskDetailView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(Router.self) private var router
|
||||
|
||||
let taskId: String
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
private var task: TaskPlan? { store.task(id: taskId) }
|
||||
|
||||
var body: some View {
|
||||
if let task {
|
||||
VStack(spacing: 0) {
|
||||
header(task)
|
||||
Divider()
|
||||
stepList(task)
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.focusable()
|
||||
.focused($focused)
|
||||
.focusEffectDisabled()
|
||||
.onAppear { focused = true }
|
||||
.onKeyPress(.init("s")) { startSession(); return .handled }
|
||||
.onKeyPress(.init("e"), phases: .down) { event in
|
||||
if event.modifiers.contains(.command) {
|
||||
router.push(.editor(taskId: task.id))
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.init("h"), phases: .down) { event in
|
||||
if event.modifiers.contains(.command) {
|
||||
router.push(.history(taskId: task.id))
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.init("d"), phases: .down) { event in
|
||||
if event.modifiers.contains(.command) {
|
||||
if let copy = store.duplicateTask(id: task.id) {
|
||||
router.replace(with: .detail(taskId: copy.id))
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.delete) {
|
||||
showDeleteConfirm = true
|
||||
return .handled
|
||||
}
|
||||
.confirmationDialog("Delete \(task.title)?", isPresented: $showDeleteConfirm) {
|
||||
Button("Delete", role: .destructive) {
|
||||
store.deleteTask(id: task.id)
|
||||
router.popToRoot()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Task not found", systemImage: "questionmark.folder")
|
||||
.onAppear { router.popToRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ task: TaskPlan) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button { router.pop() } label: {
|
||||
Image(systemName: "chevron.left").foregroundStyle(Theme.dim)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.leftArrow, modifiers: [])
|
||||
|
||||
Text(task.title).monoTitle().lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(task.totalEstimation)m").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stepList(_ task: TaskPlan) -> some View {
|
||||
if task.steps.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Text("no steps").monoBody().foregroundStyle(Theme.dim)
|
||||
Text("⌘E add steps").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(task.steps) { step in
|
||||
HStack(spacing: 8) {
|
||||
Text(Theme.Glyph.pending).foregroundStyle(Theme.dim)
|
||||
Text(step.title).monoBody().lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(step.estimation)m").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: Theme.rowHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text("s start").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("⌘E edit").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("⌘H history").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("⌘D duplicate").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("⌫ delete").monoSmall().foregroundStyle(Theme.dim)
|
||||
Spacer()
|
||||
Text("esc back").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
guard let task, !task.steps.isEmpty else { return }
|
||||
store.initRecord(taskId: task.id)
|
||||
// Reset any prior end and start fresh.
|
||||
store.reset(taskId: task.id)
|
||||
store.startStepRecord(taskId: task.id, stepId: task.steps[0].id)
|
||||
router.push(.record(taskId: task.id))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user