fix(ui): media Apply missing-printer notice visible in manage sheet (#170) #171

Merged
gronod merged 1 commits from fix/170-media-library-missing-printer-notice into develop 2026-09-14 14:37:17 +01:00
3 changed files with 33 additions and 25 deletions
+9
View File
@@ -220,6 +220,15 @@ struct ManageMediaDialog: View {
.accessibilityIdentifier("mediaLibraryList") .accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260) .frame(minHeight: 260)
if let notice = media.manageApplyNotice {
Text(notice)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("manageMediaNotice")
.accessibilityValue(notice)
}
HStack { HStack {
Button("Apply selected") { Button("Apply selected") {
if let id = selection, if let id = selection,
+21 -24
View File
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
@Published var saveMediaApplyCal = false @Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id roster complete). /// Inline caption inside the capture sheet (no a11y id roster complete).
@Published var saveMediaError: String? @Published var saveMediaError: String?
/// Last failed Apply while Manage is open. The window banner sits
/// behind the sheet on Monterey, so the dialog shows this too (#170).
@Published var manageApplyNotice: String?
/// Pure flow flag the manage sheet's "Capture current" asks the /// Pure flow flag the manage sheet's "Capture current" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a /// sheet's `onDismiss` to open the capture sheet, avoiding a
@@ -120,24 +123,21 @@ final class MediaLibraryViewModel: ObservableObject {
/// with warning; the refusal is permanent so re-clicking can't help). /// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult @discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool { func apply(_ recipe: MediaRecipe) async -> Bool {
manageApplyNotice = nil
guard let r = try? recipe.validated() else { guard let r = try? recipe.validated() else {
workflow.wizard.showNotice( return failApply("Media recipe is invalid — not applied.", kind: .error)
"Media recipe is invalid — not applied.", kind: .error)
return false
} }
guard let preset = environment.presetStore.all() guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID }) .first(where: { $0.id == r.presetID })
else { else {
workflow.wizard.showNotice( return failApply(
"Preset \(r.presetID) no longer exists — recipe not applied.", "Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error) kind: .error)
return false
} }
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
workflow.wizard.showNotice( return failApply(
"Recipe colour space does not match its preset — not applied.", "Recipe colour space does not match its preset — not applied.",
kind: .error) kind: .error)
return false
} }
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields. // Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
@@ -145,8 +145,6 @@ final class MediaLibraryViewModel: ObservableObject {
// Literal per issue: displayName, not the queue id. // Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName workflow.wizard.printerName = r.printerDisplayName
var succeeded = true
// Queue: enumerate fresh via the session's serialized path // Queue: enumerate fresh via the session's serialized path
// listPrinters uses fixed process ids, so an overlapping // listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a // enumeration would throw duplicateID. An empty result is a
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
workflow.print.selectedPrinter = r.printerID workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities() await workflow.print.reloadSelectedCapabilities()
} else { } else {
workflow.wizard.showNotice( return failApply(
"Printer \(r.printerDisplayName) is not installed.", "Printer \(r.printerDisplayName) is not installed.",
kind: .warning) kind: .warning)
succeeded = false
} }
} else { } else {
workflow.wizard.showNotice( return failApply(
"Could not enumerate printers — queue left unchanged.", "Could not enumerate printers — queue left unchanged.",
kind: .warning) kind: .warning)
succeeded = false
} }
// Calibration the recipe is authoritative and runs after // Calibration the recipe is authoritative and runs after
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
guard FileManager.default.fileExists(atPath: calPath) else { guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
workflow.wizard.showNotice( return failApply(
"Calibration file is missing: \(calPath)", kind: .error) "Calibration file is missing: \(calPath)", kind: .error)
refreshStaleness()
return false
} }
do { do {
let staleDays = environment.settingsStore.load().calibrationStaleDays let staleDays = environment.settingsStore.load().calibrationStaleDays
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
} }
} catch { } catch {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.wizard.showNotice( return failApply(
"Could not load calibration: \(error.localizedDescription)", "Could not load calibration: \(error.localizedDescription)",
kind: .error) kind: .error)
refreshStaleness()
return false
} }
} else { } else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
} }
if succeeded { selectedRecipeID = r.id
selectedRecipeID = r.id workflow.wizard.showNotice("Applied \(r.name)")
workflow.wizard.showNotice("Applied \(r.name)")
}
refreshStaleness() refreshStaleness()
return succeeded return true
}
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
manageApplyNotice = text
workflow.wizard.showNotice(text, kind: kind)
refreshStaleness()
return false
} }
// MARK: - Capture // MARK: - Capture
@@ -148,7 +148,9 @@ final class Milestone10MediaLibraryUITests: XCTestCase {
XCTAssertTrue(apply.waitForExistence(timeout: 10)) XCTAssertTrue(apply.waitForExistence(timeout: 10))
apply.click() apply.click()
let notice = waitFor("noticeText") // The window banner (`noticeText`) sits behind this sheet on
// Monterey (#170). Assert the in-sheet copy instead.
let notice = waitFor("manageMediaNotice", timeout: 15)
let text = (notice.value as? String) ?? notice.label let text = (notice.value as? String) ?? notice.label
XCTAssertTrue( XCTAssertTrue(
text.contains("is not installed"), text.contains("is not installed"),