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:
147
macos/Sources/FailWell/Views/CommandPalette.swift
Normal file
147
macos/Sources/FailWell/Views/CommandPalette.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CommandPalette: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(Router.self) private var router
|
||||
|
||||
@State private var query: String = ""
|
||||
@State private var selection: Int = 0
|
||||
@FocusState private var fieldFocused: Bool
|
||||
|
||||
private struct Command: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let hint: String
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
private var commands: [Command] {
|
||||
var list: [Command] = []
|
||||
let activeTaskId = store.activeRecord?.task.id
|
||||
|
||||
list.append(.init(id: "new", label: "new task", hint: "create a blank task") {
|
||||
router.push(.editor(taskId: nil))
|
||||
})
|
||||
list.append(.init(id: "paste", label: "paste task from clipboard", hint: "import markdown") {
|
||||
if let draft = ClipboardImporter.currentDraft() {
|
||||
let t = TaskPlan(id: UUID().uuidString, title: draft.title ?? "Untitled", stepHistory: [draft.steps])
|
||||
store.upsertTask(t)
|
||||
router.push(.detail(taskId: t.id))
|
||||
}
|
||||
})
|
||||
if let activeTaskId {
|
||||
list.append(.init(id: "open-record", label: "open active recording", hint: "jump to current session") {
|
||||
router.push(.record(taskId: activeTaskId))
|
||||
})
|
||||
list.append(.init(id: "pause", label: "pause / resume session", hint: "toggle break") {
|
||||
if let r = store.record(taskId: activeTaskId), r.isPaused {
|
||||
store.resume(taskId: activeTaskId)
|
||||
} else {
|
||||
store.pause(taskId: activeTaskId)
|
||||
}
|
||||
})
|
||||
list.append(.init(id: "next", label: "next step", hint: "advance current step") {
|
||||
store.nextStep(taskId: activeTaskId)
|
||||
})
|
||||
}
|
||||
list.append(.init(id: "settings", label: "settings", hint: "global hotkey, etc.") {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
})
|
||||
list.append(.init(id: "reveal", label: "reveal data file in finder", hint: "~/Library/Application Support/FailWell") {
|
||||
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("FailWell", isDirectory: true)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
})
|
||||
list.append(.init(id: "quit", label: "quit fail well", hint: "⌘Q") {
|
||||
NSApp.terminate(nil)
|
||||
})
|
||||
|
||||
// Recently-used tasks as "start session" entries.
|
||||
for task in store.tasksByRecency.prefix(8) {
|
||||
list.append(.init(id: "start-\(task.id)", label: "start session: \(task.title)", hint: "\(task.steps.count) steps · \(task.totalEstimation)m") {
|
||||
store.initRecord(taskId: task.id)
|
||||
store.reset(taskId: task.id)
|
||||
if let first = task.steps.first {
|
||||
store.startStepRecord(taskId: task.id, stepId: first.id)
|
||||
router.push(.record(taskId: task.id))
|
||||
}
|
||||
})
|
||||
list.append(.init(id: "open-\(task.id)", label: "open: \(task.title)", hint: "task detail") {
|
||||
router.push(.detail(taskId: task.id))
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
private var filtered: [Command] {
|
||||
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
guard !q.isEmpty else { return commands }
|
||||
return commands.filter { fuzzyMatch(q, in: $0.label.lowercased()) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextField(">", text: $query, prompt: Text("type to search commands"))
|
||||
.textFieldStyle(.plain)
|
||||
.font(Theme.monoTitle)
|
||||
.padding(12)
|
||||
.focused($fieldFocused)
|
||||
.onSubmit { runSelected() }
|
||||
.onChange(of: query) { _, _ in selection = 0 }
|
||||
Divider()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(filtered.enumerated()), id: \.element.id) { i, cmd in
|
||||
HStack(spacing: 8) {
|
||||
Text(i == selection ? "▸" : " ").monoBody().foregroundStyle(Theme.accent)
|
||||
Text(cmd.label).monoBody()
|
||||
Spacer()
|
||||
Text(cmd.hint).monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: Theme.rowHeight)
|
||||
.background(i == selection ? Theme.accent.opacity(0.12) : .clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection = i; runSelected() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: Theme.popoverWidth - 40, height: Theme.popoverHeight - 80)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor)))
|
||||
.shadow(radius: 12)
|
||||
.onAppear { fieldFocused = true; selection = 0 }
|
||||
.onKeyPress(.downArrow) {
|
||||
selection = min(selection + 1, filtered.count - 1)
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.upArrow) {
|
||||
selection = max(0, selection - 1)
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
router.showPalette = false
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
private func runSelected() {
|
||||
let list = filtered
|
||||
guard selection >= 0, selection < list.count else { return }
|
||||
list[selection].run()
|
||||
router.showPalette = false
|
||||
}
|
||||
|
||||
private func fuzzyMatch(_ needle: String, in haystack: String) -> Bool {
|
||||
var hIdx = haystack.startIndex
|
||||
for ch in needle {
|
||||
guard let found = haystack[hIdx...].firstIndex(of: ch) else { return false }
|
||||
hIdx = haystack.index(after: found)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
98
macos/Sources/FailWell/Views/MenuBarPopover.swift
Normal file
98
macos/Sources/FailWell/Views/MenuBarPopover.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
204
macos/Sources/FailWell/Views/RecordView.swift
Normal file
204
macos/Sources/FailWell/Views/RecordView.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RecordView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(Clock.self) private var clock
|
||||
|
||||
let taskId: String
|
||||
|
||||
@FocusState private var notesFocused: Bool
|
||||
@State private var showResetConfirm = false
|
||||
|
||||
private var task: TaskPlan? { store.task(id: taskId) }
|
||||
private var record: TaskRecord? { store.record(taskId: taskId) }
|
||||
|
||||
@FocusState private var rootFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
if let task, let record {
|
||||
VStack(spacing: 0) {
|
||||
header(task, record)
|
||||
Divider()
|
||||
stepList(task, record)
|
||||
Divider()
|
||||
notes
|
||||
Divider()
|
||||
controls(task, record)
|
||||
}
|
||||
.focusable()
|
||||
.focused($rootFocused)
|
||||
.focusEffectDisabled()
|
||||
.onAppear { rootFocused = true }
|
||||
.onKeyPress(.init("s")) { startIfNeeded(task, record) }
|
||||
.onKeyPress(.init("n")) { nextIfPossible(record) }
|
||||
.onKeyPress(.init("p")) { togglePause(record) }
|
||||
.onKeyPress(.init("r")) { showResetConfirm = true; return .handled }
|
||||
.onKeyPress(.init("i")) {
|
||||
notesFocused = true
|
||||
return .handled
|
||||
}
|
||||
.confirmationDialog("Reset session?", isPresented: $showResetConfirm) {
|
||||
Button("Reset", role: .destructive) { store.reset(taskId: taskId) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Record not found", systemImage: "clock.badge.questionmark")
|
||||
.onAppear { router.popToRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ task: TaskPlan, _ record: TaskRecord) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) }
|
||||
.buttonStyle(.plain)
|
||||
Text(task.title).monoTitle().lineLimit(1)
|
||||
Spacer()
|
||||
if record.isPaused {
|
||||
Text(Theme.Glyph.paused).foregroundStyle(Theme.flag)
|
||||
} else if record.currentStepId != nil {
|
||||
Text(Theme.Glyph.active).foregroundStyle(Theme.accent)
|
||||
}
|
||||
Text(elapsedTotal(record)).monoTimer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func stepList(_ task: TaskPlan, _ record: TaskRecord) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(task.steps) { step in
|
||||
StepRow(
|
||||
step: step,
|
||||
initialEstimation: task.initialEstimation(for: step.id),
|
||||
record: record,
|
||||
now: clock.now
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var notes: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("notes").monoSmall().foregroundStyle(Theme.dim).padding(.horizontal, 12).padding(.top, 6)
|
||||
TextEditor(text: notesBinding)
|
||||
.font(Theme.mono)
|
||||
.frame(height: 60)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 6)
|
||||
.focused($notesFocused)
|
||||
}
|
||||
}
|
||||
|
||||
private var notesBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { store.record(taskId: taskId)?.notes ?? "" },
|
||||
set: { store.updateNotes(taskId: taskId, notes: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func controls(_ task: TaskPlan, _ record: TaskRecord) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
if record.currentStepId == nil && !record.hasStarted {
|
||||
Text("s start").monoSmall().foregroundStyle(Theme.dim)
|
||||
} else if record.end != nil {
|
||||
Text("session ended").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("r reset").monoSmall().foregroundStyle(Theme.dim)
|
||||
} else {
|
||||
Text("n next").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text(record.isPaused ? "p resume" : "p pause").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("r reset").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("i notes").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
Spacer()
|
||||
Text("esc back").monoSmall().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func startIfNeeded(_ task: TaskPlan, _ record: TaskRecord) -> KeyPress.Result {
|
||||
guard record.currentStepId == nil, !record.hasStarted, !task.steps.isEmpty else { return .ignored }
|
||||
store.startStepRecord(taskId: task.id, stepId: task.steps[0].id)
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func nextIfPossible(_ record: TaskRecord) -> KeyPress.Result {
|
||||
guard record.currentStepId != nil, !record.isPaused else { return .ignored }
|
||||
store.nextStep(taskId: taskId)
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func togglePause(_ record: TaskRecord) -> KeyPress.Result {
|
||||
if record.isPaused {
|
||||
store.resume(taskId: taskId)
|
||||
} else {
|
||||
store.pause(taskId: taskId)
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func elapsedTotal(_ record: TaskRecord) -> String {
|
||||
let total = record.stepRecords.values.reduce(into: 0.0) { acc, range in
|
||||
let end = range.end ?? (record.currentStepId.flatMap { record.stepRecords[$0]?.start == range.start ? clock.now : nil } ?? range.start)
|
||||
acc += end.timeIntervalSince(range.start)
|
||||
}
|
||||
return TimeFormat.clock(total)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StepRow: View {
|
||||
let step: Step
|
||||
/// Estimation from the Initial Plan, or `nil` if this Step was added mid-Execution.
|
||||
/// The Failure Signal is computed against this — never the current estimation.
|
||||
let initialEstimation: Int?
|
||||
let record: TaskRecord
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let isCurrent = record.currentStepId == step.id
|
||||
let range = record.stepRecords[step.id]
|
||||
let isDone = range?.end != nil
|
||||
let elapsed: TimeInterval? = {
|
||||
guard let range else { return nil }
|
||||
return (range.end ?? (isCurrent ? now : range.start)).timeIntervalSince(range.start)
|
||||
}()
|
||||
let flagged: Bool = {
|
||||
guard let elapsed, isDone, let baseline = initialEstimation else { return false }
|
||||
let actualMinutes = elapsed / 60
|
||||
return EstimationComparator.is10PercentOff(estimation: Double(baseline), duration: actualMinutes)
|
||||
}()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(glyph(isCurrent: isCurrent, isDone: isDone))
|
||||
.foregroundStyle(isCurrent ? Theme.accent : (isDone ? .green : Theme.dim))
|
||||
.frame(width: 12)
|
||||
Text(step.title)
|
||||
.monoBody()
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isDone ? Theme.dim : .primary)
|
||||
if flagged {
|
||||
Text(Theme.Glyph.flagged).foregroundStyle(Theme.flag)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(step.estimation)m").monoSmall().foregroundStyle(Theme.dim).frame(width: 36, alignment: .trailing)
|
||||
if let elapsed {
|
||||
Text(TimeFormat.clock(elapsed)).monoTimer().frame(width: 56, alignment: .trailing)
|
||||
} else {
|
||||
Text("—").monoSmall().foregroundStyle(Theme.dim).frame(width: 56, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: Theme.rowHeight)
|
||||
.background(isCurrent ? Theme.accent.opacity(0.12) : .clear)
|
||||
}
|
||||
|
||||
private func glyph(isCurrent: Bool, isDone: Bool) -> String {
|
||||
if isCurrent { return Theme.Glyph.active }
|
||||
if isDone { return Theme.Glyph.done }
|
||||
return Theme.Glyph.pending
|
||||
}
|
||||
}
|
||||
37
macos/Sources/FailWell/Views/Router.swift
Normal file
37
macos/Sources/FailWell/Views/Router.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Router {
|
||||
enum Screen: Hashable, Sendable {
|
||||
case list
|
||||
case detail(taskId: String)
|
||||
case editor(taskId: String?)
|
||||
case record(taskId: String)
|
||||
case history(taskId: String)
|
||||
case settings
|
||||
}
|
||||
|
||||
var stack: [Screen] = [.list]
|
||||
var showPalette: Bool = false
|
||||
|
||||
var current: Screen { stack.last ?? .list }
|
||||
|
||||
func push(_ screen: Screen) {
|
||||
stack.append(screen)
|
||||
}
|
||||
|
||||
func replace(with screen: Screen) {
|
||||
stack = [screen]
|
||||
}
|
||||
|
||||
func pop() {
|
||||
guard stack.count > 1 else { return }
|
||||
stack.removeLast()
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
stack = [.list]
|
||||
}
|
||||
}
|
||||
17
macos/Sources/FailWell/Views/SettingsView.swift
Normal file
17
macos/Sources/FailWell/Views/SettingsView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import KeyboardShortcuts
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Global hotkey") {
|
||||
KeyboardShortcuts.Recorder("Toggle popover:", name: .togglePopover)
|
||||
Text("Three modifiers + a letter is recommended — single- and two-modifier combos collide with system shortcuts.")
|
||||
.font(Theme.monoSmall)
|
||||
.foregroundStyle(Theme.dim)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 420, height: 200)
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
142
macos/Sources/FailWell/Views/TaskEditor.swift
Normal file
142
macos/Sources/FailWell/Views/TaskEditor.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskEditor: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(Router.self) private var router
|
||||
|
||||
let taskId: String?
|
||||
|
||||
@State private var title: String = ""
|
||||
@State private var stepsText: String = ""
|
||||
@State private var link: String = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field { case title, steps, link }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
form
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.onAppear(perform: load)
|
||||
.onKeyPress(.escape) {
|
||||
// Save & exit if anything entered, else just exit.
|
||||
saveAndExit()
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.init("s"), phases: .down) { event in
|
||||
if event.modifiers.contains(.command) {
|
||||
saveAndExit()
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) }
|
||||
.buttonStyle(.plain)
|
||||
Text(taskId == nil ? "new task" : "edit task").monoTitle()
|
||||
Spacer()
|
||||
Text("\(totalEstimation)m").monoSmall().foregroundStyle(Theme.dim)
|
||||
Button("save") { saveAndExit() }
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
LabeledField(label: "title") {
|
||||
TextField("", text: $title, prompt: Text("e.g. refactor parser"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(Theme.mono)
|
||||
.focused($focusedField, equals: .title)
|
||||
}
|
||||
LabeledField(label: "steps") {
|
||||
TextEditor(text: $stepsText)
|
||||
.font(Theme.mono)
|
||||
.frame(minHeight: 220)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(6)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color(nsColor: .separatorColor)))
|
||||
.focused($focusedField, equals: .steps)
|
||||
}
|
||||
Text("format: `- step | minutes`. indent with spaces or tab to group children under a parent.")
|
||||
.monoSmall().foregroundStyle(Theme.dim)
|
||||
|
||||
LabeledField(label: "link") {
|
||||
TextField("", text: $link, prompt: Text("optional"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(Theme.mono)
|
||||
.focused($focusedField, equals: .link)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.onAppear { focusedField = .title }
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text("⌘↵ or ⌘S save").monoSmall().foregroundStyle(Theme.dim)
|
||||
Text("esc save & back").monoSmall().foregroundStyle(Theme.dim)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var totalEstimation: Int {
|
||||
StepParser.adaptTextareaToSteps(stepsText).reduce(0) { $0 + $1.estimation }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let taskId, let task = store.task(id: taskId) else { return }
|
||||
title = task.title
|
||||
stepsText = StepParser.adaptStepsToTextarea(task.steps)
|
||||
link = task.link ?? ""
|
||||
}
|
||||
|
||||
private func saveAndExit() {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
||||
let steps = StepParser.adaptTextareaToSteps(stepsText)
|
||||
guard !trimmedTitle.isEmpty, !steps.isEmpty else {
|
||||
router.pop()
|
||||
return
|
||||
}
|
||||
let id = taskId ?? UUID().uuidString
|
||||
let existing = taskId.flatMap { store.task(id: $0) }
|
||||
let stepHistory = (existing?.stepHistory ?? []) + [steps]
|
||||
let task = TaskPlan(
|
||||
id: id,
|
||||
title: trimmedTitle,
|
||||
date: existing?.date ?? Date(),
|
||||
link: link.isEmpty ? nil : link,
|
||||
stepHistory: stepHistory
|
||||
)
|
||||
store.upsertTask(task)
|
||||
router.replace(with: .detail(taskId: id))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LabeledField<Content: View>: View {
|
||||
let label: String
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).monoSmall().foregroundStyle(Theme.dim)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
84
macos/Sources/FailWell/Views/TaskHistoryView.swift
Normal file
84
macos/Sources/FailWell/Views/TaskHistoryView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskHistoryView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(Router.self) private var router
|
||||
|
||||
let taskId: String
|
||||
|
||||
private var task: TaskPlan? { store.task(id: taskId) }
|
||||
private var record: TaskRecord? { store.record(taskId: taskId) }
|
||||
|
||||
var body: some View {
|
||||
if let task {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) }
|
||||
.buttonStyle(.plain)
|
||||
Text("history · \(task.title)").monoTitle().lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
Divider()
|
||||
if let record, !record.stepRecords.isEmpty {
|
||||
summary(task: task, record: record)
|
||||
} else {
|
||||
VStack {
|
||||
Text("no recorded session yet").monoBody().foregroundStyle(Theme.dim)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Task not found", systemImage: "questionmark.folder")
|
||||
.onAppear { router.popToRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
private func summary(task: TaskPlan, record: TaskRecord) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(task.steps) { step in
|
||||
let range = record.stepRecords[step.id]
|
||||
let actual = range?.duration.map { $0 / 60 } ?? 0
|
||||
// Failure Signal: variance vs the Initial Plan, not the current Plan.
|
||||
// CONTEXT.md §"Failure Signal".
|
||||
let baseline = task.initialEstimation(for: step.id)
|
||||
let flagged = range?.end != nil && baseline.map {
|
||||
EstimationComparator.is10PercentOff(estimation: Double($0), duration: actual)
|
||||
} ?? false
|
||||
HStack(spacing: 8) {
|
||||
Text(range?.end != nil ? Theme.Glyph.done : Theme.Glyph.pending)
|
||||
.foregroundStyle(range?.end != nil ? .green : Theme.dim)
|
||||
.frame(width: 12)
|
||||
Text(step.title).monoBody().lineLimit(1)
|
||||
if flagged { Text(Theme.Glyph.flagged).foregroundStyle(Theme.flag) }
|
||||
Spacer()
|
||||
Group {
|
||||
if let baseline {
|
||||
Text("est \(baseline)m").monoSmall()
|
||||
} else {
|
||||
Text("(added)").monoSmall()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Theme.dim)
|
||||
Text("actual \(Int(actual.rounded()))m").monoSmall()
|
||||
.foregroundStyle(flagged ? Theme.flag : .primary)
|
||||
.frame(width: 80, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: Theme.rowHeight)
|
||||
}
|
||||
if !record.notes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("notes").monoSmall().foregroundStyle(Theme.dim).padding(.top, 8)
|
||||
Text(record.notes).monoBody()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
macos/Sources/FailWell/Views/TaskListView.swift
Normal file
160
macos/Sources/FailWell/Views/TaskListView.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
32
macos/Sources/FailWell/Views/Theme.swift
Normal file
32
macos/Sources/FailWell/Views/Theme.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
static let mono: Font = .system(size: 12, weight: .regular, design: .monospaced)
|
||||
static let monoSmall: Font = .system(size: 11, weight: .regular, design: .monospaced)
|
||||
static let monoBold: Font = .system(size: 12, weight: .semibold, design: .monospaced)
|
||||
static let monoTitle: Font = .system(size: 13, weight: .semibold, design: .monospaced)
|
||||
static let monoTimer: Font = .system(size: 14, weight: .medium, design: .monospaced).monospacedDigit()
|
||||
|
||||
static let rowHeight: CGFloat = 24
|
||||
static let popoverWidth: CGFloat = 420
|
||||
static let popoverHeight: CGFloat = 520
|
||||
|
||||
static let dim: Color = .secondary
|
||||
static let accent: Color = .accentColor
|
||||
static let flag: Color = .orange
|
||||
|
||||
enum Glyph {
|
||||
static let pending = "·"
|
||||
static let active = "▶"
|
||||
static let done = "✓"
|
||||
static let flagged = "!"
|
||||
static let paused = "⏸"
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func monoBody() -> some View { self.font(Theme.mono) }
|
||||
func monoSmall() -> some View { self.font(Theme.monoSmall) }
|
||||
func monoTitle() -> some View { self.font(Theme.monoTitle) }
|
||||
func monoTimer() -> some View { self.font(Theme.monoTimer) }
|
||||
}
|
||||
Reference in New Issue
Block a user