App scaffold & wizard shell (#1) #33
+4
-1
@@ -13,7 +13,10 @@ DerivedData/
|
||||
Package.resolved
|
||||
|
||||
# Fetched Argyll sidecars (release artefacts, not git blobs — #127)
|
||||
Resources/argyll/
|
||||
Vendor/Argyll/
|
||||
|
||||
# XcodeGen output (regenerate with `make gen`)
|
||||
ICCery.xcodeproj/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
SCHEME := ICCery
|
||||
DEST := 'platform=macOS'
|
||||
|
||||
.PHONY: gen build test universal fetch-argyll clean
|
||||
|
||||
gen:
|
||||
xcodegen generate
|
||||
|
||||
build: gen
|
||||
xcodebuild build -scheme $(SCHEME) -destination $(DEST)
|
||||
|
||||
test: gen
|
||||
xcodebuild build test -scheme $(SCHEME) -destination $(DEST)
|
||||
|
||||
universal: gen
|
||||
xcodebuild build -scheme $(SCHEME) -destination $(DEST) \
|
||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
|
||||
fetch-argyll:
|
||||
scripts/fetch-argyll.sh
|
||||
|
||||
clean:
|
||||
rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build
|
||||
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ICCeryCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ICCeryCore"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Well-known filesystem locations for the ICCery host process.
|
||||
///
|
||||
/// macOS paths (docs/02 §Persistence):
|
||||
/// - App data: `~/Library/Application Support/<bundle-id>/`
|
||||
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
|
||||
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
|
||||
public enum AppPaths {
|
||||
|
||||
/// `com.gronod.iccery2` — read from the main bundle so tests can override.
|
||||
public static var bundleIdentifier: String {
|
||||
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
|
||||
}
|
||||
|
||||
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||
public static var appDataDir: URL {
|
||||
FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `~/Library/Logs/com.gronod.iccery2`
|
||||
public static var logDir: URL {
|
||||
FileManager.default
|
||||
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Logs", isDirectory: true)
|
||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
||||
public static var logFile: URL {
|
||||
logDir.appendingPathComponent("iccery.log", isDirectory: false)
|
||||
}
|
||||
|
||||
/// `<app>/Contents/Resources/Argyll` — bundled sidecar root.
|
||||
public static var bundledArgyllDir: URL {
|
||||
Bundle.main.resourceURL?
|
||||
.appendingPathComponent("Argyll", isDirectory: true)
|
||||
?? URL(fileURLWithPath: "/nonexistent")
|
||||
}
|
||||
|
||||
/// Creates the app data and log directories if missing.
|
||||
@discardableResult
|
||||
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
|
||||
return (appDataDir, logDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// The five wizard stages plus Stage 0 (calibration), matching the v1
|
||||
/// `data-stage` contract in docs/21. Stepper buttons 1–5 map to
|
||||
/// `.generate` … `.verifyInstall`; `.calibrate` lives outside the stepper.
|
||||
public enum WizardStage: Int, CaseIterable, Sendable, Codable {
|
||||
case calibrate = 0
|
||||
case generate = 1
|
||||
case layOutPrint = 2
|
||||
case measure = 3
|
||||
case buildProfile = 4
|
||||
case verifyInstall = 5
|
||||
|
||||
/// Sidebar stepper position (1–5); `nil` for the out-of-band calibrate stage.
|
||||
public var stepperIndex: Int? {
|
||||
self == .calibrate ? nil : rawValue
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .calibrate: return "Printer Calibration"
|
||||
case .generate: return "Generate Target"
|
||||
case .layOutPrint: return "Lay Out & Print"
|
||||
case .measure: return "Measure Chart"
|
||||
case .buildProfile: return "Build Profile"
|
||||
case .verifyInstall: return "Verify & Install"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .calibrate: return "slider.horizontal.3"
|
||||
case .generate: return "square.grid.3x3"
|
||||
case .layOutPrint: return "printer"
|
||||
case .measure: return "eyedropper.halffull"
|
||||
case .buildProfile: return "paintpalette"
|
||||
case .verifyInstall: return "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
/// Stages shown in the sidebar stepper, in order.
|
||||
public static var stepperStages: [WizardStage] {
|
||||
[.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.800",
|
||||
"green" : "0.478",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ICCery-logo.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "original"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
|
||||
<defs>
|
||||
<clipPath id="coneClip">
|
||||
<path d="M 50 82 L 110 82 L 80 142 Z" />
|
||||
</clipPath>
|
||||
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00AEEF" />
|
||||
<stop offset="100%" stop-color="#0066CC" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Drop Shadow -->
|
||||
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
|
||||
|
||||
<!-- Ice Cream Cone (Waffle) -->
|
||||
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
|
||||
<line x1="40" y1="80" x2="120" y2="160" />
|
||||
<line x1="55" y1="80" x2="135" y2="160" />
|
||||
<line x1="70" y1="80" x2="150" y2="160" />
|
||||
<line x1="85" y1="80" x2="165" y2="160" />
|
||||
|
||||
<line x1="120" y1="80" x2="40" y2="160" />
|
||||
<line x1="105" y1="80" x2="25" y2="160" />
|
||||
<line x1="90" y1="80" x2="10" y2="160" />
|
||||
<line x1="75" y1="80" x2="-5" y2="160" />
|
||||
</g>
|
||||
|
||||
<!-- CMYK Scoops (C, M, Y) -->
|
||||
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
|
||||
|
||||
<!-- Black (Key) Cherry -->
|
||||
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
|
||||
|
||||
<!-- Highlights on scoops for 3D effect -->
|
||||
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
|
||||
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||
|
||||
<!-- Text: ICCery -->
|
||||
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,33 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ICCeryApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var model = WizardViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||
Window("ICCery", id: "main") {
|
||||
RootView(model: model)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 800)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultPosition(.center)
|
||||
}
|
||||
}
|
||||
|
||||
/// AppDelegate: quit when the single window closes, and give later
|
||||
/// milestones a hook to `killAll` Argyll children before teardown
|
||||
/// (#147/#149 — wired once ProcessManager exists in #2).
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Issue #2+: ProcessManager.shared.killAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Banner notice model — the v2 equivalent of `#wizardNotification`
|
||||
/// (docs/21 §Banner).
|
||||
struct Notice: Identifiable, Equatable {
|
||||
enum Kind: Equatable {
|
||||
case info, warning, error
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .info: return "info.circle"
|
||||
case .warning: return "exclamationmark.triangle"
|
||||
case .error: return "xmark.octagon"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .info: return Theme.accent
|
||||
case .warning: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let text: String
|
||||
/// Auto-dismiss interval; `nil` keeps the banner until closed.
|
||||
var autoHideAfter: TimeInterval? = 6
|
||||
}
|
||||
|
||||
struct NoticeBanner: View {
|
||||
let notice: Notice
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: notice.kind.symbolName)
|
||||
.foregroundStyle(notice.kind.tint)
|
||||
Text(notice.text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(3)
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Theme.panel)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundStyle(Theme.border),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||
/// banner pinned to the top (docs/21 §Shell).
|
||||
struct RootView: View {
|
||||
@Bindable var model: WizardViewModel
|
||||
@State private var showingSettings = false
|
||||
@State private var showingAbout = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
SidebarView(
|
||||
model: model,
|
||||
onOpenSettings: { showingSettings = true },
|
||||
onOpenAbout: { showingAbout = true }
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(Theme.border)
|
||||
.frame(width: 1)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if let notice = model.notice {
|
||||
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
||||
}
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.background(Theme.background)
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
// Full settings dialog lands in issue #5.
|
||||
VStack(spacing: 12) {
|
||||
Text("Settings").font(.headline)
|
||||
Text("Implemented in issue #5.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Close") { showingSettings = false }
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 420)
|
||||
}
|
||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||
struct SidebarView: View {
|
||||
@Bindable var model: WizardViewModel
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Image("ICCery-logo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
Spacer()
|
||||
Button(action: onOpenSettings) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Settings")
|
||||
Button(action: onOpenAbout) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("About ICCery")
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
// Preset select (`#presetSelect`). Preset engine lands in #11.
|
||||
Picker("Preset", selection: .constant("none")) {
|
||||
Text("No preset").tag("none")
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip`
|
||||
// is hidden until the calibration library lands in #29.
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Stepper 1–5.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(WizardStage.stepperStages, id: \.self) { stage in
|
||||
StepperRow(
|
||||
stage: stage,
|
||||
isActive: model.stage == stage
|
||||
) {
|
||||
model.go(to: stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StepperRow: View {
|
||||
let stage: WizardStage
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isActive ? Theme.accent : Theme.border)
|
||||
.frame(width: 26, height: 26)
|
||||
Text("\(stage.stepperIndex ?? 0)")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(isActive ? .white : Theme.text)
|
||||
}
|
||||
Label(stage.title, systemImage: stage.symbolName)
|
||||
.font(.callout)
|
||||
.foregroundStyle(isActive ? Theme.text : .secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
||||
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
||||
struct StagePlaceholderView: View {
|
||||
let stage: WizardStage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: stage.symbolName)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text(stage.title)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("This stage is not implemented yet — see the milestone plan.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens).
|
||||
enum Theme {
|
||||
static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255)
|
||||
static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255)
|
||||
static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255)
|
||||
static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255)
|
||||
static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255)
|
||||
/// v1 window/titlebar backing colour (docs/02 §Window contract).
|
||||
static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255)
|
||||
|
||||
enum Metrics {
|
||||
static let sidebarWidth: CGFloat = 270
|
||||
static let buttonSmall: CGFloat = 28
|
||||
static let buttonMedium: CGFloat = 36
|
||||
static let buttonLarge: CGFloat = 40
|
||||
static let cornerSmall: CGFloat = 4
|
||||
static let cornerMedium: CGFloat = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// Wizard shell state (issue #1). Artefact gating, persistence and the
|
||||
/// "open existing" flow land in issue #4.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WizardViewModel {
|
||||
/// Currently displayed stage.
|
||||
var stage: WizardStage = .generate
|
||||
|
||||
/// Banner notice currently displayed (`#wizardNotification`).
|
||||
var notice: Notice?
|
||||
|
||||
/// Target basename shared across stages (`targetBasename`).
|
||||
var basename: String = ""
|
||||
|
||||
/// Working directory for all Argyll artefacts.
|
||||
var workingDirectory: URL?
|
||||
|
||||
/// Printer queue selected in Stage 2; retained across stages.
|
||||
var printerName: String?
|
||||
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
|
||||
/// `true` while Stage 0 (printer calibration) is shown instead of a
|
||||
/// stepper stage.
|
||||
var isCalibrating: Bool { stage == .calibrate }
|
||||
|
||||
func go(to stage: WizardStage) {
|
||||
self.stage = stage
|
||||
}
|
||||
|
||||
func enterCalibration() {
|
||||
stage = .calibrate
|
||||
}
|
||||
|
||||
func exitCalibration() {
|
||||
stage = .generate
|
||||
}
|
||||
|
||||
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
||||
noticeDismissTask?.cancel()
|
||||
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||
self.notice = notice
|
||||
if let delay = notice.autoHideAfter {
|
||||
noticeDismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
if self?.notice?.id == notice.id {
|
||||
self?.notice = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissNotice() {
|
||||
noticeDismissTask?.cancel()
|
||||
notice = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("AppPaths")
|
||||
struct AppPathsTests {
|
||||
@Test func appDataDirUsesBundleID() {
|
||||
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func logFileIsUnderLibraryLogs() {
|
||||
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
||||
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func bundledArgyllDirIsInsideResources() {
|
||||
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStage")
|
||||
struct WizardStageTests {
|
||||
@Test func stepperOrderIsOneThroughFive() {
|
||||
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
||||
#expect(WizardStage.calibrate.stepperIndex == nil)
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
name: ICCery
|
||||
options:
|
||||
bundleIdPrefix: com.gronod
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
groupSortPosition: top
|
||||
|
||||
packages:
|
||||
ICCeryCore:
|
||||
path: Packages/ICCeryCore
|
||||
|
||||
targets:
|
||||
ICCery:
|
||||
type: application
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
sources:
|
||||
- path: Sources/ICCery
|
||||
- path: Resources
|
||||
excludes:
|
||||
- ICCery.entitlements
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
PRODUCT_NAME: ICCery
|
||||
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
|
||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
ENABLE_APP_SANDBOX: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
ARCHS: "$(ARCHS_STANDARD)"
|
||||
|
||||
ICCeryCoreTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
sources:
|
||||
- path: Tests/ICCeryCoreTests
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
- target: ICCery
|
||||
settings:
|
||||
base:
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
|
||||
schemes:
|
||||
ICCery:
|
||||
build:
|
||||
targets:
|
||||
ICCery: all
|
||||
ICCeryCoreTests: [test]
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
gatherCoverageData: false
|
||||
targets:
|
||||
- ICCeryCoreTests
|
||||
Reference in New Issue
Block a user