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.
133 lines
4.7 KiB
Swift
133 lines
4.7 KiB
Swift
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))
|
|
}
|
|
}
|