diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift index 48303ab..d40b262 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -15,14 +15,26 @@ public enum ArtefactFiles { try Data(contentsOf: url).base64EncodedString() } - /// `get_app_info` — version + build for the About dialog. + /// `get_app_info` — version, build, and build date for the About dialog. public static func appInfo( bundle: Bundle = .main - ) -> (version: String, build: String) { + ) -> (version: String, build: String, buildDate: String) { let info = bundle.infoDictionary ?? [:] - return ( - info["CFBundleShortVersionString"] as? String ?? "0.0.0", - info["CFBundleVersion"] as? String ?? "0" - ) + let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0" + let build = info["CFBundleVersion"] as? String ?? "0" + + let url = bundle.executableURL ?? bundle.bundleURL + let buildDate: String + if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + buildDate = formatter.string(from: date) + } else { + buildDate = "Unknown" + } + + return (version, build, buildDate) } } diff --git a/Sources/ICCery/AboutView.swift b/Sources/ICCery/AboutView.swift new file mode 100644 index 0000000..6396f8f --- /dev/null +++ b/Sources/ICCery/AboutView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import ICCeryCore + +/// About dialog for ICCery (issue #31, docs/21 §Modals). +struct AboutView: View { + let onClose: () -> Void + + private let info = ArtefactFiles.appInfo() + + var body: some View { + VStack(spacing: 20) { + Image("ICCery-logo") + .resizable() + .scaledToFit() + .frame(height: 64) + + Text("ICCery") + .font(.title) + .foregroundStyle(Theme.text) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Version:") + .foregroundStyle(.secondary) + Text(info.version) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutVersion") + } + HStack { + Text("Build:") + .foregroundStyle(.secondary) + Text(info.build) + .foregroundStyle(Theme.text) + } + HStack { + Text("Build date:") + .foregroundStyle(.secondary) + Text(info.buildDate) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutBuildDate") + } + } + .font(.callout) + + Text("Native macOS printer profiling workstation.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Close") { + onClose() + } + .controlSize(.large) + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("closeAboutBtn") + } + .padding(32) + .frame(width: 360) + .background(Theme.panel) + .accessibilityIdentifier("aboutDialog") + } +} diff --git a/Sources/ICCery/HelpOverlayView.swift b/Sources/ICCery/HelpOverlayView.swift new file mode 100644 index 0000000..99574b5 --- /dev/null +++ b/Sources/ICCery/HelpOverlayView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reusable help overlay badge that does not reflow layout (#171). +/// +/// When `showing` is `true`, a small indicator is rendered as an overlay at the +/// top-trailing corner of the wrapped view. The native `.help` tooltip is always +/// available on hover, so the overlay is purely a visual cue in help mode. +struct HelpOverlay: ViewModifier { + let text: String + @Binding var showing: Bool + + func body(content: Content) -> some View { + content + .help(text) + .overlay(alignment: .topTrailing) { + if showing { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.accent) + .offset(x: 8, y: -8) + } + } + } +} + +extension View { + /// Adds a non-reflowing help overlay to the view. + func helpOverlay(_ text: String, showing: Binding) -> some View { + modifier(HelpOverlay(text: text, showing: showing)) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 48d1ff2..501f8f1 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -8,6 +8,7 @@ struct RootView: View { @Bindable var workflow: TargetWorkflowViewModel @State private var showingSettings = false @State private var showingAbout = false + @State private var showingAllHelp = false private var model: WizardViewModel { workflow.wizard } @@ -16,7 +17,8 @@ struct RootView: View { SidebarView( workflow: workflow, onOpenSettings: { showingSettings = true }, - onOpenAbout: { showingAbout = true } + onOpenAbout: { showingAbout = true }, + showingAllHelp: $showingAllHelp ) Rectangle() @@ -48,10 +50,8 @@ struct RootView: View { .sheet(isPresented: $workflow.showingManagePresets) { ManagePresetsDialog(workflow: workflow) } - .alert("ICCery 2.0.0", isPresented: $showingAbout) { - Button("OK") {} - } message: { - Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") + .sheet(isPresented: $showingAbout) { + AboutView { showingAbout = false } } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index f2ed065..54268fc 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -7,6 +7,7 @@ struct SidebarView: View { @Bindable var workflow: TargetWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void + @Binding var showingAllHelp: Bool private var model: WizardViewModel { workflow.wizard } @@ -22,12 +23,20 @@ struct SidebarView: View { Image(systemName: "gearshape") } .buttonStyle(.plain) - .help("Settings") + .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openSettingsBtn") Button(action: onOpenAbout) { Image(systemName: "info.circle") } .buttonStyle(.plain) - .help("About ICCery") + .helpOverlay("Open the About dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openAboutBtn") + Button(action: { showingAllHelp.toggle() }) { + Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle") + } + .buttonStyle(.plain) + .help("Toggle help overlays") + .accessibilityIdentifier("btnToggleAllHelp") } .padding(12) diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift new file mode 100644 index 0000000..c6c2b03 --- /dev/null +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// About and help chrome UI tests (issue #31). +@MainActor +final class AboutHelpUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + private func element(_ id: String) -> XCUIElement { + app.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + func testAboutDialogShowsVersionAndBuildDate() throws { + app.launch() + app.activate() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + _ = waitFor("aboutDialog", timeout: 10) + XCTAssertTrue(element("aboutVersion").exists) + XCTAssertTrue(element("aboutBuildDate").exists) + + let close = app.buttons["closeAboutBtn"] + XCTAssertTrue(close.exists) + close.click() + + XCTAssertFalse(element("aboutDialog").exists) + } + + func testHelpOverlaysDoNotChangeSidebarHeight() throws { + app.launch() + app.activate() + + let toggle = app.buttons["btnToggleAllHelp"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10)) + + let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element + let before = sidebar.frame + + toggle.click() + let after = sidebar.frame + + XCTAssertEqual(before.size.height, after.size.height, + "Toggling global help must not reflow the sidebar height.") + XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists) + } +}