Files
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ef56cdd7d4 Fix M5 regressions and Stage 3 chartread prompt delivery (#50, #52).
Hardens ProcessManager finalisation, ensures stdout/stderr pipe write-ends
close after spawn, sets termination handlers before run(), and fixes the
runCaptured continuation hand-off for fast exits. Resolves the Stage 3
prompt stream being dropped and the handheld fixture UI not advancing,
unskips and repairs the handheld UI test, and fixes averaging panel
accessibility. Includes the selected #52 profile, verification, and
artefact-gating fixes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 13:25:39 +01:00

386 lines
14 KiB
Swift

import Combine
import SwiftUI
import ICCeryCore
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
instrumentSection
chartreadControlsSection
xyTableSection
swatchGridSection
averagingSection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.discoverPassSnapshots() }
.onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in
model.loadSettings()
model.recomputeSwatches()
}
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
if model.resumedFromTi2 {
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("stage3LoadedTargetBanner")
}
Text(model.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBasename")
Text("Measure the printed chart with a spectrophotometer.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage3TargetMeta")
}
Spacer()
if model.isChartreadRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("readProgress")
}
if let badge = targetBadge {
Text(badge)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBadge")
}
}
.padding(16)
.background(Theme.panel)
}
private var targetBadge: String? {
if model.isFinished { return "All strips read" }
if model.isChartreadRunning { return "Reading" }
if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" }
return nil
}
// MARK: - Instrument detection
@ViewBuilder
private var instrumentSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnDetectInstruments")
}
if model.detectionError != nil {
Text(model.detectionError ?? "")
.font(.caption)
.foregroundStyle(.red)
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("chartreadInstrumentSelect")
if model.selectedInstrument.isXY {
Text("XY table workflow selected.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("xyTableHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Chartread controls
@ViewBuilder
private var chartreadControlsSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.currentPrompt ?? "Press Start to begin reading.")
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadPrompt")
}
if model.showRemoveSheetNotice {
Text("Please remove last sheet from table.")
.font(.caption)
.foregroundStyle(Theme.accent)
}
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError)
}
controlButtons
if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.chartreadLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private var controlButtons: some View {
HStack(spacing: 12) {
if !model.isChartreadRunning {
Button("Start Read") {
model.startRead()
}
.disabled(!model.canStartRead)
.accessibilityIdentifier("btnStartRead")
}
if model.isChartreadRunning {
switch model.chartreadState {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate")
case .awaitingStrip:
Button("Trigger") { model.calibrate() }
.accessibilityIdentifier("btnTrigger")
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneReadEarly")
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
Button(continueTitle) { model.accept() }
.accessibilityIdentifier("btnAccept")
case .error:
Button("Retry") { model.retry() }
.accessibilityIdentifier("btnRetry")
case .allStripsRead:
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneRead")
default:
EmptyView()
}
Button("Cancel") { model.cancelRead() }
.accessibilityIdentifier("btnCancel")
}
}
}
private var continueTitle: String {
if let key = model.requestedWarningKey {
return "Continue (send '\(key.uppercased())')"
}
return "Continue"
}
// MARK: - XY table badges
@ViewBuilder
private var xyTableSection: some View {
if model.selectedInstrument.isXY {
HStack(spacing: 8) {
xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace")
xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign")
xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan")
xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove")
}
.padding(12)
.background(Theme.panel)
.accessibilityIdentifier("xyTablePanel")
}
}
private func xyStep(_ label: String, active: Bool, id: String) -> some View {
Text(label)
.font(.caption)
.fontWeight(active ? .bold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(active ? Theme.accent : Theme.border)
.foregroundStyle(active ? Color.white : Theme.text)
.cornerRadius(4)
.accessibilityIdentifier(id)
}
// MARK: - Swatch grid
@ViewBuilder
private var swatchGridSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Swatches")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
statsView
}
ScrollView([.horizontal, .vertical]) {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.swatchRows) { row in
HStack(spacing: 2) {
Text(row.rowId)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 24)
ForEach(row.patches) { swatch in
SwatchPatchView(swatch: swatch)
}
}
}
}
.padding(8)
}
.frame(minHeight: 120, maxHeight: 360)
.background(Theme.panel)
.accessibilityIdentifier("swatchGrid")
}
.padding(16)
.background(Theme.background)
}
@ViewBuilder
private var statsView: some View {
let patches = model.swatchRows.flatMap(\.patches)
let valid = patches.compactMap(\.deltaE)
let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count)
let max = valid.max() ?? 0
HStack(spacing: 12) {
if let avg = avg {
Text("avg ΔE \(String(format: "%.2f", avg))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("max ΔE \(String(format: "%.2f", max))")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(patches.count) patches")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("readStats")
}
// MARK: - Averaging
@ViewBuilder
private var averagingSection: some View {
if !model.passSnapshots.isEmpty || model.isFinished {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Averaging")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text("\(model.passSnapshots.count) pass(es)")
.font(.caption)
.foregroundStyle(Theme.text)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.accessibilityIdentifier("passCounterBadge")
}
ForEach(model.passSnapshots, id: \.lastPathComponent) { url in
Text(url.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("passesList")
HStack(spacing: 12) {
Button("Measure Another Sheet") {
model.measureAnotherSheet()
}
.disabled(!model.isFinished || model.isChartreadRunning)
.accessibilityIdentifier("btnMeasureAnotherSheet")
Button("Finish & Average") {
model.finishAndAverage()
}
.disabled(!model.canFinish || model.isFinishing)
.accessibilityIdentifier("btnFinishAndAverage")
}
if let notice = model.finishNotice {
Text(notice)
.font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
}
}
.padding(16)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("chartreadAveragingPanel")
}
}
}