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.
161 lines
5.3 KiB
Swift
161 lines
5.3 KiB
Swift
import SwiftUI
|
|
|
|
struct TaskListView: View {
|
|
@Environment(AppStore.self) private var store
|
|
@Environment(Router.self) private var router
|
|
|
|
@State private var selection: Int = 0
|
|
@FocusState private var focused: Bool
|
|
|
|
private var tasks: [TaskPlan] { store.tasksByRecency }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
if tasks.isEmpty {
|
|
emptyState
|
|
} else {
|
|
list
|
|
}
|
|
Divider()
|
|
footer
|
|
}
|
|
.focusable()
|
|
.focused($focused)
|
|
.focusEffectDisabled()
|
|
.onAppear { focused = true }
|
|
.onKeyPress(.init("j")) {
|
|
selection = min(selection + 1, tasks.count - 1)
|
|
return .handled
|
|
}
|
|
.onKeyPress(.init("k")) {
|
|
selection = max(0, selection - 1)
|
|
return .handled
|
|
}
|
|
.onKeyPress(.downArrow) {
|
|
selection = min(selection + 1, tasks.count - 1)
|
|
return .handled
|
|
}
|
|
.onKeyPress(.upArrow) {
|
|
selection = max(0, selection - 1)
|
|
return .handled
|
|
}
|
|
.onKeyPress(.return) {
|
|
openSelected()
|
|
return .handled
|
|
}
|
|
.onKeyPress(.init("n"), phases: .down) { event in
|
|
if event.modifiers.contains(.command) {
|
|
router.push(.editor(taskId: nil))
|
|
return .handled
|
|
}
|
|
return .ignored
|
|
}
|
|
.onKeyPress(.init("v"), phases: .down) { event in
|
|
if event.modifiers.contains(.command) {
|
|
importFromClipboard()
|
|
return .handled
|
|
}
|
|
return .ignored
|
|
}
|
|
.onKeyPress(.delete) { deleteSelected() }
|
|
.onKeyPress(.deleteForward) { deleteSelected() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text("tasks").monoTitle().foregroundStyle(Theme.dim)
|
|
Spacer()
|
|
Text("\(tasks.count)").monoSmall().foregroundStyle(Theme.dim)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var list: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(tasks.enumerated()), id: \.element.id) { index, task in
|
|
TaskRow(task: task, selected: index == selection)
|
|
.id(task.id)
|
|
.onTapGesture { selection = index; openSelected() }
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: selection) { _, new in
|
|
guard new >= 0, new < tasks.count else { return }
|
|
proxy.scrollTo(tasks[new].id, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 8) {
|
|
Text("no tasks yet").monoBody().foregroundStyle(Theme.dim)
|
|
Text("⌘N new · ⌘V paste · ⌘K commands").monoSmall().foregroundStyle(Theme.dim)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: 12) {
|
|
Text("j/k move").monoSmall().foregroundStyle(Theme.dim)
|
|
Text("↵ open").monoSmall().foregroundStyle(Theme.dim)
|
|
Text("⌘N new").monoSmall().foregroundStyle(Theme.dim)
|
|
Text("⌘V paste").monoSmall().foregroundStyle(Theme.dim)
|
|
Spacer()
|
|
Text("⌘K palette").monoSmall().foregroundStyle(Theme.dim)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func openSelected() {
|
|
guard selection >= 0, selection < tasks.count else { return }
|
|
router.push(.detail(taskId: tasks[selection].id))
|
|
}
|
|
|
|
private func deleteSelected() -> KeyPress.Result {
|
|
guard selection >= 0, selection < tasks.count else { return .ignored }
|
|
let id = tasks[selection].id
|
|
store.deleteTask(id: id)
|
|
selection = max(0, min(selection, tasks.count - 1))
|
|
return .handled
|
|
}
|
|
|
|
private func importFromClipboard() {
|
|
guard let draft = ClipboardImporter.currentDraft() else { return }
|
|
let task = TaskPlan(
|
|
id: UUID().uuidString,
|
|
title: draft.title ?? "Untitled",
|
|
date: Date(),
|
|
link: nil,
|
|
stepHistory: [draft.steps]
|
|
)
|
|
store.upsertTask(task)
|
|
router.push(.detail(taskId: task.id))
|
|
}
|
|
}
|
|
|
|
private struct TaskRow: View {
|
|
let task: TaskPlan
|
|
let selected: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Text(selected ? "▸" : " ").monoBody().foregroundStyle(Theme.accent)
|
|
Text(task.title).monoBody().lineLimit(1)
|
|
Spacer()
|
|
Text("\(task.steps.count)").monoSmall().foregroundStyle(Theme.dim).frame(width: 24, alignment: .trailing)
|
|
Text("\(task.totalEstimation)m").monoSmall().foregroundStyle(Theme.dim).frame(width: 36, alignment: .trailing)
|
|
Text(TimeFormat.date(task.date)).monoSmall().foregroundStyle(Theme.dim).frame(width: 70, alignment: .trailing)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(height: Theme.rowHeight)
|
|
.background(selected ? Theme.accent.opacity(0.12) : .clear)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|