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.
205 lines
7.8 KiB
Swift
205 lines
7.8 KiB
Swift
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
|
|
}
|
|
}
|