diff --git a/poc/app-shots-redesign/index.html b/poc/app-shots-redesign/index.html new file mode 100644 index 0000000..5182ed8 --- /dev/null +++ b/poc/app-shots-redesign/index.html @@ -0,0 +1,1051 @@ + + + + +App Shots — light workspace + + + + +
+ + +
+
+
Overview
+
Simulator
+
Tests
+
Icon
+
App Shots
+
+ + + + + +
+
+
+ demo + · + App Shots +
+ + + Ready · 0 captures + +
+ + + +
+ + + +
+ + + + +
+
+
Screenshot sets
+
0 of 8 sets
+
+
+
+
+

Your 8 sets will appear here

+

Capture screens on the left and pick a headline on the right — we'll render 8 polished template sets in about a minute.

+
+
01
CaptureScreens from your simulator.
+
02
WriteOne headline for all sets.
+
03
Generate8 ready-to-ship sets.
+
+
+ +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+
+
Bold Gradient
+
+ + Bold + · + 8 shots + · + Plan your day in seconds +
+
+
+ + + +
+ +
+
+
+ +
+ +
Uploading sends these 8 PNGs to the en-US locale in App Store Connect.
+ +
+
+
+ + +
+ + + + + + +
+ + + + diff --git a/src/AppState.swift b/src/AppState.swift index 2f5ddd1..ec9b74d 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -118,6 +118,7 @@ enum AppSubTab: String, CaseIterable, Identifiable { case database case tests case icon + case appShots /// Tabs currently shown in the UI (database is hidden for now). static let visibleCases: [AppSubTab] = allCases.filter { $0 != .database } @@ -131,6 +132,7 @@ enum AppSubTab: String, CaseIterable, Identifiable { case .database: "Database" case .tests: "Tests" case .icon: "Icon" + case .appShots: "App Shots" } } @@ -141,6 +143,7 @@ enum AppSubTab: String, CaseIterable, Identifiable { case .database: "cylinder" case .tests: "checkmark.circle" case .icon: "photo.badge.plus" + case .appShots: "camera.on.rectangle" } } } diff --git a/src/managers/app/AppShotsFlowManager.swift b/src/managers/app/AppShotsFlowManager.swift new file mode 100644 index 0000000..1d07900 --- /dev/null +++ b/src/managers/app/AppShotsFlowManager.swift @@ -0,0 +1,400 @@ +import Foundation +import AppKit + +/// Owns the App Shots batch flow: current step, captures, form inputs, generated sets. +@MainActor +@Observable +final class AppShotsFlowManager { + // MARK: - Observable state + + var step: AppShotsStep = .hero + var captures: [CapturedShot] = [] + /// Fallback headline — used for any capture whose per-row headline is blank. + var defaultHeadline: String = "" + /// Fallback subtitle — if blank, the copywriter varies per template. + var defaultSubtitle: String = "" + var useFrame: Bool = true + var selectedFrameName: String = "iPhone 17 Pro Max" + var templates: [ASCManager.AppShotTemplate] = [] + var availableFrames: [DeviceFrame] = [] + var generated: [GeneratedSet] = [] + var isRecording: Bool = false + var isCapturing: Bool = false + var captureError: String? + var generationError: String? + + // MARK: - Collaborators + + private let recorder = FlowRecorder() + private let compositor = DeviceFrameCompositor() + private var currentProjectId: String? + private var didLoadForProject: String? + + // MARK: - Derived + + var selectedFrame: DeviceFrame? { + availableFrames.first(where: { $0.name == selectedFrameName }) + } + + var includedCaptures: [CapturedShot] { captures.filter { $0.included } } + var blankWarningCount: Int { captures.filter { $0.warning != nil }.count } + + var canGenerate: Bool { !includedCaptures.isEmpty && !templates.isEmpty } + + var totalRendersExpected: Int { + guard !generated.isEmpty else { return 0 } + return generated.reduce(0) { $0 + $1.screenshots.count } + } + + var totalRendersDone: Int { + generated.reduce(0) { $0 + $1.readyCount } + } + + // MARK: - Lifecycle + + func bootstrap(projectId: String?, projectName: String) async { + currentProjectId = projectId + + if availableFrames.isEmpty { + availableFrames = compositor.frames + if !availableFrames.contains(where: { $0.name == selectedFrameName }) { + selectedFrameName = availableFrames.first(where: { $0.name.contains("17 Pro Max") })?.name + ?? availableFrames.first?.name ?? selectedFrameName + } + } + + if templates.isEmpty { + templates = (try? await ASCManager.appShotsTemplatesList()) ?? [] + } + + guard didLoadForProject != projectId else { return } + didLoadForProject = projectId + + captures = [] + generated = [] + generationError = nil + captureError = nil + + if let projectId, let persisted = AppShotsStore(projectId: projectId).load() { + adopt(persisted: persisted) + step = .done + } else { + defaultHeadline = "" + defaultSubtitle = "" + step = .hero + } + } + + private func adopt(persisted: PersistedSets) { + defaultHeadline = persisted.headline + defaultSubtitle = persisted.subtitle ?? "" + if let frameName = persisted.deviceFrameName { + selectedFrameName = frameName + useFrame = true + } + generated = AppShotsStore.rehydrate(persisted) + } + + // MARK: - Navigation + + func startBuilding() { step = .capture } + func backToHero() { step = .hero } + func regenerate() { + generated = [] + captures = [] + generationError = nil + step = .capture + } + + /// Hard reset — clear all in-memory state, delete persisted sets, return to hero. + func resetToHero() { + captures = [] + generated = [] + defaultHeadline = "" + defaultSubtitle = "" + generationError = nil + captureError = nil + if isRecording { recorder.stop(); isRecording = false } + if let projectId = currentProjectId { + let store = AppShotsStore(projectId: projectId) + try? FileManager.default.removeItem(atPath: "\(store.outputDir)/sets.json") + } + step = .hero + } + + // MARK: - Per-capture copy + + /// Resolve the effective headline for a capture: its own override, else default, else project name. + func effectiveHeadline(for capture: CapturedShot, projectName: String) -> String { + if !capture.headline.isEmpty { return capture.headline } + if !defaultHeadline.isEmpty { return defaultHeadline } + return projectName + } + + /// Resolve the effective subtitle for a capture: its own override, else default, else nil + /// (nil triggers the copywriter's per-template variation). + func effectiveSubtitle(for capture: CapturedShot) -> String? { + if !capture.subtitle.isEmpty { return capture.subtitle } + if !defaultSubtitle.isEmpty { return defaultSubtitle } + return nil + } + + func updateCaptureHeadline(id: UUID, headline: String) { + guard let idx = captures.firstIndex(where: { $0.id == id }) else { return } + captures[idx].headline = headline + } + + func updateCaptureSubtitle(id: UUID, subtitle: String) { + guard let idx = captures.firstIndex(where: { $0.id == id }) else { return } + captures[idx].subtitle = subtitle + } + + // MARK: - Capture + + func captureOnce(bootedUDID: String?) async { + captureError = nil + guard let udid = bootedUDID else { captureError = "No booted simulator."; return } + isCapturing = true + defer { isCapturing = false } + do { + let shot = try await AppShotsCapture.snapCurrentSimulator(udid: udid) + captures.append(shot) + } catch { + captureError = "Capture failed: \(error.localizedDescription)" + } + } + + func toggleRecording(bootedUDID: String?) { + if isRecording { + recorder.stop() + isRecording = false + return + } + guard let udid = bootedUDID else { captureError = "No booted simulator."; return } + captureError = nil + isRecording = true + recorder.start(udid: udid) { [weak self] shot in + self?.captures.append(shot) + } + } + + func importFiles() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg] + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.message = "Select screenshots" + guard panel.runModal() == .OK else { return } + for url in panel.urls { + if let image = NSImage(contentsOf: url) { + captures.append(CapturedShot(path: url.path, image: image)) + } + } + } + + func removeCapture(id: UUID) { + captures.removeAll { $0.id == id } + } + + func toggleCaptureInclusion(id: UUID) { + guard let idx = captures.firstIndex(where: { $0.id == id }) else { return } + captures[idx].included.toggle() + } + + // MARK: - Generate + + func generate(projectName: String) async { + let activeCaptures = includedCaptures + guard !activeCaptures.isEmpty else { return } + guard let projectId = currentProjectId else { + generationError = "No active project." + return + } + + let chosen = AppShotsGenerator.pickTemplates(from: templates, limit: 8) + guard !chosen.isEmpty else { + generationError = "No templates available." + return + } + + generationError = nil + let fallbackHeadline = defaultHeadline.isEmpty ? projectName : defaultHeadline + let fallbackSubtitle = defaultSubtitle.isEmpty ? nil : defaultSubtitle + let frame = useFrame ? selectedFrame : nil + + // Seed each set with empty screenshot placeholders for included captures only. + let labels: [UUID: String] = Dictionary( + uniqueKeysWithValues: activeCaptures.enumerated().map { ($0.element.id, "Screen \($0.offset + 1)") } + ) + generated = chosen.map { template in + let setCopy = AppShotsCopywriter.copy( + base: fallbackHeadline, + userSubtitle: fallbackSubtitle, + category: template.category, + seed: projectName + ) + let placeholders = activeCaptures.map { capture in + GeneratedScreenshot( + captureId: capture.id, + captureLabel: labels[capture.id] ?? "Screen", + sourceScreenshot: capture.path, + headline: capture.headline, // may be empty; fallback resolved at render time + subtitle: capture.subtitle + ) + } + return GeneratedSet( + id: template.id, + template: template, + headline: setCopy.headline, + subtitle: setCopy.subtitle, + screenshots: placeholders + ) + } + step = .generating + + let store = AppShotsStore(projectId: projectId) + store.ensureOutputDir() + + let request = GenerationRequest( + headline: fallbackHeadline, + subtitle: fallbackSubtitle, + tagline: nil, + appName: projectName.isEmpty ? nil : projectName, + captures: activeCaptures, + frame: frame, + projectName: projectName, + outputDir: store.outputDir + ) + + await AppShotsGenerator.run( + request: request, + templates: chosen, + frameCompositor: useFrame ? compositor : nil + ) { [weak self] outcome in + self?.applyOutcome(outcome) + } + + let snapshot = AppShotsStore.snapshot( + headline: fallbackHeadline, + subtitle: fallbackSubtitle, + deviceFrame: frame, + sets: generated + ) + store.save(snapshot) + step = .done + } + + private func applyOutcome(_ outcome: AppShotsGenerator.Outcome) { + guard let setIdx = generated.firstIndex(where: { $0.id == outcome.templateId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.captureId == outcome.captureId }) + else { return } + + switch outcome.result { + case .success(let path): + let resolved = (path as NSString).expandingTildeInPath + generated[setIdx].screenshots[shotIdx].imagePath = resolved + generated[setIdx].screenshots[shotIdx].image = NSImage(contentsOfFile: resolved) + generated[setIdx].screenshots[shotIdx].error = nil + case .failure(let error): + generated[setIdx].screenshots[shotIdx].error = error.localizedDescription + } + } + + // MARK: - Per-shot edits + + /// Update a shot's headline in place. Safe after app restart — doesn't depend on live captures. + func updateShotHeadline(setId: String, screenshotId: UUID, headline: String) { + guard let setIdx = generated.firstIndex(where: { $0.id == setId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.id == screenshotId }) + else { return } + generated[setIdx].screenshots[shotIdx].headline = headline + } + + func updateShotSubtitle(setId: String, screenshotId: UUID, subtitle: String) { + guard let setIdx = generated.firstIndex(where: { $0.id == setId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.id == screenshotId }) + else { return } + generated[setIdx].screenshots[shotIdx].subtitle = subtitle + } + + func updateShotTagline(setId: String, screenshotId: UUID, tagline: String) { + guard let setIdx = generated.firstIndex(where: { $0.id == setId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.id == screenshotId }) + else { return } + generated[setIdx].screenshots[shotIdx].tagline = tagline + } + + func updateShotAppName(setId: String, screenshotId: UUID, appName: String) { + guard let setIdx = generated.firstIndex(where: { $0.id == setId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.id == screenshotId }) + else { return } + generated[setIdx].screenshots[shotIdx].appName = appName + } + + /// Re-render a single shot using its own current copy + sourceScreenshot. + /// Used for "retry failed" and "apply edited text" — same call, same effect. + func applyShotChanges(setId: String, screenshotId: UUID, projectName: String) async { + guard let setIdx = generated.firstIndex(where: { $0.id == setId }), + let shotIdx = generated[setIdx].screenshots.firstIndex(where: { $0.id == screenshotId }), + let projectId = currentProjectId else { return } + + let shot = generated[setIdx].screenshots[shotIdx] + let template = generated[setIdx].template + + guard shot.canRender, let sourceImage = NSImage(contentsOfFile: shot.sourceScreenshot) else { + generated[setIdx].screenshots[shotIdx].error = "Source screenshot missing — can't re-render." + return + } + + // Reset status so UI shows loading again. + generated[setIdx].screenshots[shotIdx].error = nil + generated[setIdx].screenshots[shotIdx].imagePath = nil + generated[setIdx].screenshots[shotIdx].image = nil + + let store = AppShotsStore(projectId: projectId) + let headline = shot.effectiveHeadline(defaultHeadline: defaultHeadline, projectName: projectName) + let subtitle = shot.effectiveSubtitle(defaultSubtitle: defaultSubtitle) + let tagline = shot.effectiveTagline(defaultTagline: "") + let appName = shot.effectiveAppName(projectName: projectName) + let frame = useFrame ? selectedFrame : nil + + let request = GenerationRequest( + headline: headline, + subtitle: subtitle, + tagline: tagline, + appName: appName, + captures: [CapturedShot( + id: shot.captureId, + path: shot.sourceScreenshot, + image: sourceImage, + headline: headline, + subtitle: subtitle ?? "", + tagline: tagline ?? "", + appName: appName ?? "" + )], + frame: frame, + projectName: projectName, + outputDir: store.outputDir + ) + + await AppShotsGenerator.run( + request: request, + templates: [template], + frameCompositor: useFrame ? compositor : nil + ) { [weak self] outcome in + self?.applyOutcome(outcome) + } + + store.save(AppShotsStore.snapshot( + headline: defaultHeadline.isEmpty ? projectName : defaultHeadline, + subtitle: defaultSubtitle.isEmpty ? nil : defaultSubtitle, + deviceFrame: frame, + sets: generated + )) + } + + /// Back-compat alias — same behavior, kept so existing callers don't break. + func retryScreenshot(setId: String, screenshotId: UUID, projectName: String) async { + await applyShotChanges(setId: setId, screenshotId: screenshotId, projectName: projectName) + } +} diff --git a/src/managers/asc/ASCAppShotsManager.swift b/src/managers/asc/ASCAppShotsManager.swift new file mode 100644 index 0000000..4345e48 --- /dev/null +++ b/src/managers/asc/ASCAppShotsManager.swift @@ -0,0 +1,255 @@ +import Foundation + +// MARK: - App Shots Manager +// Wraps `asc app-shots` CLI commands for screenshot generation via templates and themes. + +extension ASCManager { + + // MARK: - Models + + struct AppShotTemplate: Codable, Identifiable, Sendable { + let id: String + let name: String + let category: String + let description: String + let deviceCount: Int + let palette: Palette? + + struct Palette: Codable, Sendable { + let id: String + let name: String + let background: String? + } + } + + struct AppShotTheme: Codable, Identifiable, Sendable { + let id: String + let name: String + let icon: String + let description: String + let accent: String? + let previewGradient: String? + } + + // MARK: - Templates + + /// List available screenshot templates from `asc app-shots templates list`. + nonisolated static func appShotsTemplatesList() async throws -> [AppShotTemplate] { + let output = try await ProcessRunner.run( + "asc", + arguments: ["app-shots", "templates", "list", "--output", "json"] + ) + let json = extractJSON(from: output) + let data = Data(json.utf8) + let wrapper = try JSONDecoder().decode(DataWrapper<[AppShotTemplate]>.self, from: data) + return wrapper.data + } + + /// Apply a template to a screenshot, producing a PNG. + /// Returns the path to the generated image. + nonisolated static func appShotsTemplatesApply( + templateId: String, + screenshot: String, + headline: String, + subtitle: String? = nil, + tagline: String? = nil, + appName: String? = nil, + imageOutput: String? = nil + ) async throws -> String { + var args = [ + "app-shots", "templates", "apply", + "--id", templateId, + "--screenshot", screenshot, + "--headline", headline, + "--preview", "image", + ] + if let subtitle { args += ["--subtitle", subtitle] } + if let tagline { args += ["--tagline", tagline] } + if let appName { args += ["--app-name", appName] } + if let imageOutput { args += ["--image-output", imageOutput] } + + let output = try await ProcessRunner.run("asc", arguments: args, timeout: 60) + // The CLI prints the output path or JSON with the path + return parseImageOutputPath(from: output, fallback: imageOutput) + } + + /// Get a single template's preview HTML via `asc app-shots templates get --id --preview`. + nonisolated static func appShotsTemplatePreviewHTML(templateId: String) async throws -> String { + try await ProcessRunner.run( + "asc", + arguments: ["app-shots", "templates", "get", "--id", templateId, "--preview"], + timeout: 30 + ) + } + + /// Apply a template to a screenshot, returning composed HTML (not PNG). + nonisolated static func appShotsTemplatesApplyHTML( + templateId: String, + screenshot: String, + headline: String, + subtitle: String? = nil, + tagline: String? = nil, + appName: String? = nil + ) async throws -> String { + var args = [ + "app-shots", "templates", "apply", + "--id", templateId, + "--screenshot", screenshot, + "--headline", headline, + "--preview", "html", + ] + if let subtitle { args += ["--subtitle", subtitle] } + if let tagline { args += ["--tagline", tagline] } + if let appName { args += ["--app-name", appName] } + + return try await ProcessRunner.run("asc", arguments: args, timeout: 60) + } + + // MARK: - Themes + + /// List available visual themes from `asc app-shots themes list`. + nonisolated static func appShotsThemesList() async throws -> [AppShotTheme] { + let output = try await ProcessRunner.run( + "asc", + arguments: ["app-shots", "themes", "list", "--output", "json"] + ) + let json = extractJSON(from: output) + let data = Data(json.utf8) + let wrapper = try JSONDecoder().decode(DataWrapper<[AppShotTheme]>.self, from: data) + return wrapper.data + } + + /// Apply a theme to a template with a screenshot, producing a PNG. + /// Returns the path to the generated image. + nonisolated static func appShotsThemesApply( + themeId: String, + templateId: String, + screenshot: String, + headline: String? = nil, + subtitle: String? = nil, + tagline: String? = nil, + canvasWidth: Int? = nil, + canvasHeight: Int? = nil, + imageOutput: String? = nil + ) async throws -> String { + var args = [ + "app-shots", "themes", "apply", + "--theme", themeId, + "--template", templateId, + "--screenshot", screenshot, + "--preview", "image", + ] + if let headline { args += ["--headline", headline] } + if let subtitle { args += ["--subtitle", subtitle] } + if let tagline { args += ["--tagline", tagline] } + if let canvasWidth { args += ["--canvas-width", "\(canvasWidth)"] } + if let canvasHeight { args += ["--canvas-height", "\(canvasHeight)"] } + if let imageOutput { args += ["--image-output", imageOutput] } + + let output = try await ProcessRunner.run("asc", arguments: args, timeout: 300) + return parseImageOutputPath(from: output, fallback: imageOutput) + } + + // MARK: - Generate (Gemini AI Enhance) + + /// Enhance a screenshot using Gemini AI via `asc app-shots generate`. + /// Returns the path to the enhanced image. + nonisolated static func appShotsGenerate( + file: String, + outputDir: String? = nil, + styleReference: String? = nil, + deviceType: String? = nil, + prompt: String? = nil, + model: String? = nil + ) async throws -> String { + var args = ["app-shots", "generate", "--file", file, "--output", "json"] + if let outputDir { args += ["--output-dir", outputDir] } + if let styleReference { args += ["--style-reference", styleReference] } + if let deviceType { args += ["--device-type", deviceType] } + if let prompt { args += ["--prompt", prompt] } + if let model { args += ["--model", model] } + + let output = try await ProcessRunner.run("asc", arguments: args, timeout: 180) + return parseGenerateOutput(from: output) + } + + // MARK: - Export (HTML → PNG) + + /// Render an HTML file to PNG via `asc app-shots export`. + /// Returns the path to the exported PNG. + nonisolated static func appShotsExport( + html: String, + output: String? = nil, + width: Int? = nil, + height: Int? = nil + ) async throws -> String { + var args = ["app-shots", "export", "--html", html] + if let output { args += ["--output", output] } + if let width { args += ["--width", "\(width)"] } + if let height { args += ["--height", "\(height)"] } + + let result = try await ProcessRunner.run("asc", arguments: args, timeout: 60) + return parseImageOutputPath(from: result, fallback: output) + } + + // MARK: - Helpers + + private struct DataWrapper: Decodable { + let data: T + } + + /// Extract JSON from CLI output that may contain plugin loading stderr lines. + nonisolated private static func extractJSON(from output: String) -> String { + // The JSON is the last line that starts with `{` + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("{") { + return trimmed + } + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Parse the image output path from CLI output. + nonisolated private static func parseImageOutputPath(from output: String, fallback: String?) -> String { + // Extract JSON from output (may have plugin loading lines before it) + let jsonStr = extractJSON(from: output) + if let data = jsonStr.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let path = json["outputPath"] as? String { return resolvePath(path) } + if let path = json["exported"] as? String { return resolvePath(path) } + if let path = json["path"] as? String { return resolvePath(path) } + if let path = json["output"] as? String { return resolvePath(path) } + } + // Check each line for a file path + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + for line in trimmed.split(separator: "\n").reversed() { + let l = line.trimmingCharacters(in: .whitespaces) + if l.hasSuffix(".png") && (l.hasPrefix("/") || l.hasPrefix(".")) { + return resolvePath(l) + } + } + return fallback ?? trimmed + } + + /// Resolve a potentially relative path to absolute. + nonisolated private static func resolvePath(_ path: String) -> String { + if path.hasPrefix("/") { return path } + if path.hasPrefix("~") { return (path as NSString).expandingTildeInPath } + // Relative path — resolve from home directory + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(path).path + } + + /// Parse the generate command output for the enhanced image path. + nonisolated private static func parseGenerateOutput(from output: String) -> String { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let path = json["outputPath"] as? String { return path } + if let files = json["files"] as? [String], let first = files.first { return first } + } + return parseImageOutputPath(from: trimmed, fallback: nil) + } +} diff --git a/src/models/AppShotsDomain.swift b/src/models/AppShotsDomain.swift new file mode 100644 index 0000000..2e2162f --- /dev/null +++ b/src/models/AppShotsDomain.swift @@ -0,0 +1,165 @@ +import Foundation +import AppKit + +// Value types for the App Shots batch flow. +// +// A "set" is one template × N captures. So if the user captured 5 screens and +// we render 8 templates, we get 8 sets of 5 screenshots each (40 renders total). + +enum AppShotsStep: Equatable { + case hero, capture, generating, done +} + +struct CapturedShot: Identifiable, Equatable { + let id: UUID + let path: String + let image: NSImage + var included: Bool + var warning: String? + /// Per-capture text slots. Empty means "fall back to the manager's default." + var headline: String + var subtitle: String + var tagline: String + var appName: String + + init(id: UUID = UUID(), path: String, image: NSImage, + included: Bool = true, warning: String? = nil, + headline: String = "", subtitle: String = "", + tagline: String = "", appName: String = "") { + self.id = id; self.path = path; self.image = image + self.included = included; self.warning = warning + self.headline = headline; self.subtitle = subtitle + self.tagline = tagline; self.appName = appName + } +} + +struct DeviceFrame: Hashable { + let name: String + let outputWidth: Int + let outputHeight: Int + let screenInsetX: Int + let screenInsetY: Int +} + +struct GenerationRequest { + let headline: String + let subtitle: String? + let tagline: String? + let appName: String? + let captures: [CapturedShot] + let frame: DeviceFrame? + let projectName: String + let outputDir: String +} + +/// One rendered screenshot inside a set. Carries its own copy + source path so edits +/// and retries work without needing the live `CapturedShot` (which isn't persisted). +struct GeneratedScreenshot: Identifiable { + let id: UUID + let captureId: UUID // which source capture this came from (may not be live) + let captureLabel: String // "Screen 1", "Screen 2", … + let sourceScreenshot: String + /// Editable text slots the ASC CLI accepts. Empty = fall back to defaults. + var headline: String + var subtitle: String + var tagline: String + var appName: String + var imagePath: String? + var image: NSImage? + var error: String? + + init(id: UUID = UUID(), + captureId: UUID, captureLabel: String, sourceScreenshot: String, + headline: String = "", subtitle: String = "", + tagline: String = "", appName: String = "", + imagePath: String? = nil, image: NSImage? = nil, error: String? = nil) { + self.id = id + self.captureId = captureId + self.captureLabel = captureLabel + self.sourceScreenshot = sourceScreenshot + self.headline = headline + self.subtitle = subtitle + self.tagline = tagline + self.appName = appName + self.imagePath = imagePath + self.image = image + self.error = error + } + + // MARK: - Domain behavior + + /// Resolved copy values the renderer actually uses. + /// Own value → project-level default → fallback (nil or project name). + func effectiveHeadline(defaultHeadline: String, projectName: String) -> String { + if !headline.isEmpty { return headline } + if !defaultHeadline.isEmpty { return defaultHeadline } + return projectName + } + + func effectiveSubtitle(defaultSubtitle: String) -> String? { + if !subtitle.isEmpty { return subtitle } + if !defaultSubtitle.isEmpty { return defaultSubtitle } + return nil + } + + func effectiveTagline(defaultTagline: String) -> String? { + if !tagline.isEmpty { return tagline } + if !defaultTagline.isEmpty { return defaultTagline } + return nil + } + + func effectiveAppName(projectName: String) -> String? { + if !appName.isEmpty { return appName } + return projectName.isEmpty ? nil : projectName + } + + var canRender: Bool { + !sourceScreenshot.isEmpty && FileManager.default.fileExists(atPath: sourceScreenshot) + } +} + +/// One template's "set" — the same template applied to every capture. +struct GeneratedSet: Identifiable { + let id: String // template id + let template: ASCManager.AppShotTemplate + let headline: String + let subtitle: String? + var screenshots: [GeneratedScreenshot] + + var readyCount: Int { screenshots.filter { $0.image != nil }.count } + var isReady: Bool { !screenshots.isEmpty && readyCount == screenshots.count } + var firstReady: GeneratedScreenshot? { screenshots.first(where: { $0.image != nil }) } +} + +/// JSON persisted to `~/.blitz/projects/{id}/assets/AppShots/onboarding/sets.json`. +struct PersistedSets: Codable { + let formatVersion: Int // bump when schema changes + let headline: String + let subtitle: String? + let deviceFrameName: String? + let createdAt: Date + let entries: [Entry] + + struct Entry: Codable { + let templateId: String + let templateName: String + let templateCategory: String + let paletteBackground: String? + let screenshots: [Screenshot] + } + + struct Screenshot: Codable { + let captureLabel: String + let imagePath: String + /// v3+ fields — optional so older payloads still decode. + let sourceScreenshot: String? + let headline: String? + let subtitle: String? + /// v4+ slots. + let tagline: String? + let appName: String? + } + + /// v4 adds `tagline` + `appName` per shot. + static let currentFormatVersion = 4 +} diff --git a/src/resources/frames/iPhone 16 Plus.png b/src/resources/frames/iPhone 16 Plus.png new file mode 100644 index 0000000..3bb56b9 Binary files /dev/null and b/src/resources/frames/iPhone 16 Plus.png differ diff --git a/src/resources/frames/iPhone 16 Pro Max.png b/src/resources/frames/iPhone 16 Pro Max.png new file mode 100644 index 0000000..75acb06 Binary files /dev/null and b/src/resources/frames/iPhone 16 Pro Max.png differ diff --git a/src/resources/frames/iPhone 16 Pro.png b/src/resources/frames/iPhone 16 Pro.png new file mode 100644 index 0000000..abfa160 Binary files /dev/null and b/src/resources/frames/iPhone 16 Pro.png differ diff --git a/src/resources/frames/iPhone 16.png b/src/resources/frames/iPhone 16.png new file mode 100644 index 0000000..eb048de Binary files /dev/null and b/src/resources/frames/iPhone 16.png differ diff --git a/src/resources/frames/iPhone 17 Pro Max.png b/src/resources/frames/iPhone 17 Pro Max.png new file mode 100644 index 0000000..d6fdbf0 Binary files /dev/null and b/src/resources/frames/iPhone 17 Pro Max.png differ diff --git a/src/resources/frames/iPhone 17 Pro.png b/src/resources/frames/iPhone 17 Pro.png new file mode 100644 index 0000000..43e1563 Binary files /dev/null and b/src/resources/frames/iPhone 17 Pro.png differ diff --git a/src/resources/frames/iPhone 17.png b/src/resources/frames/iPhone 17.png new file mode 100644 index 0000000..a65ff9e Binary files /dev/null and b/src/resources/frames/iPhone 17.png differ diff --git a/src/resources/frames/iPhone Air.png b/src/resources/frames/iPhone Air.png new file mode 100644 index 0000000..917c0c5 Binary files /dev/null and b/src/resources/frames/iPhone Air.png differ diff --git a/src/resources/frames/insets.json b/src/resources/frames/insets.json new file mode 100644 index 0000000..7788e8b --- /dev/null +++ b/src/resources/frames/insets.json @@ -0,0 +1,202 @@ +{ + "iPhone 11 Pro Max": { + "outputWidth": 1463, + "outputHeight": 2888, + "screenInsetX": 110, + "screenInsetY": 100, + "screenInsetTop": 100, + "screenInsetBottom": 100 + }, + "iPhone 11 Pro": { + "outputWidth": 1365, + "outputHeight": 2656, + "screenInsetX": 120, + "screenInsetY": 110, + "screenInsetTop": 110, + "screenInsetBottom": 110 + }, + "iPhone 11": { + "outputWidth": 1008, + "outputHeight": 1952, + "screenInsetX": 90, + "screenInsetY": 80, + "screenInsetTop": 80, + "screenInsetBottom": 80 + }, + "iPhone 12 Mini": { + "outputWidth": 1325, + "outputHeight": 2616, + "screenInsetX": 122, + "screenInsetY": 138, + "screenInsetTop": 138, + "screenInsetBottom": 138 + }, + "iPhone 12 Pro Max": { + "outputWidth": 1484, + "outputHeight": 2978, + "screenInsetX": 100, + "screenInsetY": 100, + "screenInsetTop": 100, + "screenInsetBottom": 100 + }, + "iPhone 12 Pro": { + "outputWidth": 1370, + "outputHeight": 2712, + "screenInsetX": 100, + "screenInsetY": 90, + "screenInsetTop": 90, + "screenInsetBottom": 90 + }, + "iPhone 12": { + "outputWidth": 1370, + "outputHeight": 2712, + "screenInsetX": 100, + "screenInsetY": 90, + "screenInsetTop": 90, + "screenInsetBottom": 90 + }, + "iPhone 13 Pro Max": { + "outputWidth": 1500, + "outputHeight": 3000, + "screenInsetX": 108, + "screenInsetY": 111, + "screenInsetTop": 111, + "screenInsetBottom": 111 + }, + "iPhone 13 Pro": { + "outputWidth": 1400, + "outputHeight": 2700, + "screenInsetX": 115, + "screenInsetY": 84, + "screenInsetTop": 84, + "screenInsetBottom": 84 + }, + "iPhone 14 Plus": { + "outputWidth": 1464, + "outputHeight": 2978, + "screenInsetX": 90, + "screenInsetY": 100, + "screenInsetTop": 100, + "screenInsetBottom": 100 + }, + "iPhone 14 Pro Max": { + "outputWidth": 1450, + "outputHeight": 2936, + "screenInsetX": 80, + "screenInsetY": 70, + "screenInsetTop": 70, + "screenInsetBottom": 70 + }, + "iPhone 14 Pro": { + "outputWidth": 1339, + "outputHeight": 2716, + "screenInsetX": 80, + "screenInsetY": 80, + "screenInsetTop": 80, + "screenInsetBottom": 80 + }, + "iPhone 14": { + "outputWidth": 1370, + "outputHeight": 2732, + "screenInsetX": 100, + "screenInsetY": 100, + "screenInsetTop": 100, + "screenInsetBottom": 100 + }, + "iPhone 15 Plus": { + "outputWidth": 1530, + "outputHeight": 3036, + "screenInsetX": 120, + "screenInsetY": 120, + "screenInsetTop": 120, + "screenInsetBottom": 120 + }, + "iPhone 15 Pro Max": { + "outputWidth": 1530, + "outputHeight": 3036, + "screenInsetX": 120, + "screenInsetY": 120, + "screenInsetTop": 120, + "screenInsetBottom": 120 + }, + "iPhone 15 Pro": { + "outputWidth": 1419, + "outputHeight": 2796, + "screenInsetX": 120, + "screenInsetY": 120, + "screenInsetTop": 120, + "screenInsetBottom": 120 + }, + "iPhone 15": { + "outputWidth": 1419, + "outputHeight": 2796, + "screenInsetX": 120, + "screenInsetY": 120, + "screenInsetTop": 120, + "screenInsetBottom": 120 + }, + "iPhone 16": { + "outputWidth": 1359, + "outputHeight": 2736, + "screenInsetX": 90, + "screenInsetY": 90, + "screenInsetTop": 102, + "screenInsetBottom": 102 + }, + "iPhone 16 Plus": { + "outputWidth": 1470, + "outputHeight": 2970, + "screenInsetX": 90, + "screenInsetY": 87, + "screenInsetTop": 99, + "screenInsetBottom": 99 + }, + "iPhone 16 Pro": { + "outputWidth": 1350, + "outputHeight": 2760, + "screenInsetX": 72, + "screenInsetY": 69, + "screenInsetTop": 81, + "screenInsetBottom": 81 + }, + "iPhone 16 Pro Max": { + "outputWidth": 1470, + "outputHeight": 3000, + "screenInsetX": 75, + "screenInsetY": 66, + "screenInsetTop": 79, + "screenInsetBottom": 79 + }, + "iPhone 17": { + "outputWidth": 1350, + "outputHeight": 2760, + "screenInsetX": 85, + "screenInsetY": 102, + "screenInsetTop": 81, + "screenInsetBottom": 81 + }, + "iPhone 17 Pro": { + "outputWidth": 1350, + "outputHeight": 2760, + "screenInsetX": 72, + "screenInsetY": 69, + "screenInsetTop": 81, + "screenInsetBottom": 81 + }, + "iPhone 17 Pro Max": { + "outputWidth": 1470, + "outputHeight": 3000, + "screenInsetX": 75, + "screenInsetY": 66, + "screenInsetTop": 80, + "screenInsetBottom": 79 + }, + "iPhone Air": { + "outputWidth": 1380, + "outputHeight": 2880, + "screenInsetX": 87, + "screenInsetY": 129, + "screenInsetTop": 81, + "screenInsetBottom": 81 + } +} \ No newline at end of file diff --git a/src/services/appshots/AppShotsCapture.swift b/src/services/appshots/AppShotsCapture.swift new file mode 100644 index 0000000..c477ffa --- /dev/null +++ b/src/services/appshots/AppShotsCapture.swift @@ -0,0 +1,108 @@ +import Foundation +import AppKit + +// Thin capture helpers. No state; callers own cancellation. + +enum AppShotsCapture { + /// Snap the currently booted simulator to a temp PNG and load it. + /// Auto-detects near-blank captures and marks them excluded. + static func snapCurrentSimulator(udid: String) async throws -> CapturedShot { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("blitz-shot-\(Int(Date().timeIntervalSince1970 * 1000)).png").path + try await SimctlClient().screenshot(udid: udid, path: path) + guard let image = NSImage(contentsOfFile: path) else { + throw NSError(domain: "AppShotsCapture", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Screenshot saved but could not be loaded."]) + } + let blank = isLikelyBlank(image) + return CapturedShot( + path: path, + image: image, + included: !blank, + warning: blank ? "Looks nearly blank — auto-excluded." : nil + ) + } + + /// Downsample to 16×16 and average luminance. Flags solid-black / solid-white / loading screens. + static func isLikelyBlank(_ image: NSImage) -> Bool { + guard let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false } + let size = 16 + let bytesPerRow = size * 4 + var pixels = [UInt8](repeating: 0, count: size * size * 4) + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let ctx = CGContext( + data: &pixels, width: size, height: size, + bitsPerComponent: 8, bytesPerRow: bytesPerRow, + space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return false } + ctx.interpolationQuality = .low + ctx.draw(cg, in: CGRect(x: 0, y: 0, width: size, height: size)) + + var lumSum = 0 + var minLum = 255 + var maxLum = 0 + let count = size * size + for i in 0.. maxLum { maxLum = lum } + } + let avg = lumSum / count + let range = maxLum - minLum + // Nearly uniform AND extreme (dark or very light) → likely blank. + return range < 12 && (avg < 20 || avg > 245) + } + + /// Cheap rolling hash over the first 4KB of a file — enough to detect "same screen" duplicates. + static func quickHash(of path: String) -> Int? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + return data.withUnsafeBytes { ptr in + var h = 5381 + for byte in ptr.bindMemory(to: UInt8.self).prefix(4096) { + h = ((h << 5) &+ h) &+ Int(byte) + } + return h + } + } +} + +/// Polling recorder — while running, captures one frame every `interval` seconds, +/// skipping duplicates by content hash. Append-on-new via `onNewShot`. +@MainActor +final class FlowRecorder { + private var task: Task? + private(set) var isRunning = false + + func start( + udid: String, + interval: TimeInterval = 2.0, + onNewShot: @MainActor @escaping (CapturedShot) -> Void + ) { + guard !isRunning else { return } + isRunning = true + task = Task { [weak self] in + var lastHash: Int? + while let self, await self.isRunning, !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + if Task.isCancelled { break } + if !(await self.isRunning) { break } + do { + let shot = try await AppShotsCapture.snapCurrentSimulator(udid: udid) + let hash = AppShotsCapture.quickHash(of: shot.path) + if hash != nil, hash == lastHash { continue } + lastHash = hash + await MainActor.run { onNewShot(shot) } + } catch { + // Ignore transient failures — just wait for the next tick. + } + } + } + } + + func stop() { + isRunning = false + task?.cancel() + task = nil + } +} diff --git a/src/services/appshots/AppShotsCopywriter.swift b/src/services/appshots/AppShotsCopywriter.swift new file mode 100644 index 0000000..d82c561 --- /dev/null +++ b/src/services/appshots/AppShotsCopywriter.swift @@ -0,0 +1,26 @@ +import Foundation + +// Offline per-category subtitle variation so 8 sets don't look identical. +// Headline is echoed verbatim. If the user supplied a subtitle we use it — never overwritten. + +enum AppShotsCopywriter { + private static let subtitleBank: [String: [String]] = [ + "bold": ["Built for people who ship.", "Move fast. Finish strong.", "Zero friction, all signal."], + "minimal": ["Less noise. More focus.", "Simple by design.", "Only what matters."], + "elegant": ["A calmer way to work.", "Crafted for the details.", "Quiet power, everyday."], + "playful": ["Your day, but way better.", "Tap in. Have fun.", "Small app, big wins."], + "professional": ["The toolkit serious teams trust.", "Enterprise-grade, human-friendly.", "Reliable, fast, and measurable."], + "showcase": ["See it. Love it. Share it.", "Designed to be shown off.", "Beautifully yours."], + "custom": ["Made just for you.", "Your app, your way."] + ] + + static func copy(base: String, userSubtitle: String?, category: String, seed: String) -> (headline: String, subtitle: String?) { + if let userSubtitle, !userSubtitle.isEmpty { + return (base, userSubtitle) + } + let bank = subtitleBank[category.lowercased()] ?? subtitleBank["custom"] ?? [] + guard !bank.isEmpty else { return (base, nil) } + let index = abs(seed.hashValue) % bank.count + return (base, bank[index]) + } +} diff --git a/src/services/appshots/AppShotsGenerator.swift b/src/services/appshots/AppShotsGenerator.swift new file mode 100644 index 0000000..2d30ec5 --- /dev/null +++ b/src/services/appshots/AppShotsGenerator.swift @@ -0,0 +1,126 @@ +import Foundation +import AppKit + +// Batch template rendering. For each template × each capture, runs `asc app-shots templates apply`, +// optionally pre-compositing a device frame onto the capture first. +// +// Reports each finished render via `onProgress(templateId, captureId, result)` so the caller +// (manager) can mutate observable state incrementally. + +enum AppShotsGenerator { + /// Pick one template per category (up to `limit`). + static func pickTemplates( + from all: [ASCManager.AppShotTemplate], + limit: Int = 8 + ) -> [ASCManager.AppShotTemplate] { + var seen = Set() + var primary: [ASCManager.AppShotTemplate] = [] + var rest: [ASCManager.AppShotTemplate] = [] + for t in all { + if seen.insert(t.category).inserted { primary.append(t) } else { rest.append(t) } + } + return Array((primary + rest).prefix(limit)) + } + + struct Outcome { + let templateId: String + let captureId: UUID + let result: Result + } + + /// Fan out templates × captures. + static func run( + request: GenerationRequest, + templates: [ASCManager.AppShotTemplate], + frameCompositor: DeviceFrameCompositor?, + onProgress: @MainActor @escaping (Outcome) -> Void + ) async { + guard !request.captures.isEmpty else { return } + + // Pre-composite each capture once if a frame is selected — reused across templates. + let sources: [(captureId: UUID, path: String)] = request.captures.map { capture in + if let frame = request.frame, + let compositor = frameCompositor, + let framed = compositor.composite(screenshotPath: capture.path, device: frame) { + return (capture.id, framed) + } + return (capture.id, capture.path) + } + + try? FileManager.default.createDirectory(atPath: request.outputDir, withIntermediateDirectories: true) + let stamp = Int(Date().timeIntervalSince1970) + + // Per-capture copy: capture's own value wins, else request-level fallback. + struct CaptureCopy { let headline: String; let subtitle: String?; let tagline: String?; let appName: String? } + let copyByCapture: [UUID: CaptureCopy] = Dictionary( + uniqueKeysWithValues: request.captures.map { capture in + let h = capture.headline.isEmpty ? request.headline : capture.headline + let s = capture.subtitle.isEmpty ? request.subtitle : capture.subtitle + let t = capture.tagline.isEmpty ? request.tagline : capture.tagline + let a = capture.appName.isEmpty ? request.appName : capture.appName + return (capture.id, CaptureCopy(headline: h, subtitle: s, tagline: t, appName: a)) + } + ) + + // Build a flat job list. ASC plugin can't handle ~48 simultaneous `apply` + // calls — it throws transient "template not found" errors. Throttle below. + struct Job { let templateId: String; let category: String; let captureId: UUID; let screenshotPath: String; let outPath: String } + var jobs: [Job] = [] + for template in templates { + for source in sources { + let outPath = "\(request.outputDir)/\(stamp)_\(template.id)_\(source.captureId.uuidString).png" + jobs.append(Job( + templateId: template.id, + category: template.category, + captureId: source.captureId, + screenshotPath: source.path, + outPath: outPath + )) + } + } + + // Cap concurrent ASC CLI invocations. + let maxConcurrent = 4 + await withTaskGroup(of: Outcome.self) { group in + var next = 0 + func enqueue() { + guard next < jobs.count else { return } + let job = jobs[next] + next += 1 + let perCapture = copyByCapture[job.captureId] + ?? CaptureCopy(headline: request.headline, subtitle: request.subtitle, tagline: request.tagline, appName: request.appName) + // Copywriter still varies subtitle per template category when user hasn't supplied one. + let copy = AppShotsCopywriter.copy( + base: perCapture.headline, + userSubtitle: perCapture.subtitle, + category: job.category, + seed: request.projectName + ) + let tagline = perCapture.tagline + let appName = perCapture.appName + group.addTask { + do { + let resultPath = try await ASCManager.appShotsTemplatesApply( + templateId: job.templateId, + screenshot: job.screenshotPath, + headline: copy.headline, + subtitle: copy.subtitle, + tagline: tagline, + appName: appName, + imageOutput: job.outPath + ) + return Outcome(templateId: job.templateId, captureId: job.captureId, result: .success(resultPath)) + } catch { + return Outcome(templateId: job.templateId, captureId: job.captureId, result: .failure(error)) + } + } + } + + for _ in 0.. PersistedSets? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: setsFilePath)) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let payload = try? decoder.decode(PersistedSets.self, from: data), + payload.formatVersion == PersistedSets.currentFormatVersion else { + return nil + } + return payload + } + + func save(_ payload: PersistedSets) { + ensureOutputDir() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + if let data = try? encoder.encode(payload) { + try? data.write(to: URL(fileURLWithPath: setsFilePath)) + } + } + + /// Rehydrate GeneratedSet models from persisted entries. Each set's screenshots are loaded from disk. + static func rehydrate(_ persisted: PersistedSets) -> [GeneratedSet] { + persisted.entries.map { entry in + let palette = entry.paletteBackground.map { bg in + ASCManager.AppShotTemplate.Palette(id: entry.templateId, name: entry.templateName, background: bg) + } + let template = ASCManager.AppShotTemplate( + id: entry.templateId, + name: entry.templateName, + category: entry.templateCategory, + description: "", + deviceCount: 1, + palette: palette + ) + let screenshots = entry.screenshots.map { s in + GeneratedScreenshot( + captureId: UUID(), + captureLabel: s.captureLabel, + sourceScreenshot: s.sourceScreenshot ?? "", + headline: s.headline ?? "", + subtitle: s.subtitle ?? "", + tagline: s.tagline ?? "", + appName: s.appName ?? "", + imagePath: s.imagePath, + image: NSImage(contentsOfFile: s.imagePath) + ) + } + return GeneratedSet( + id: entry.templateId, + template: template, + headline: persisted.headline, + subtitle: persisted.subtitle, + screenshots: screenshots + ) + } + } + + /// Snapshot the current generated sets for persistence. + static func snapshot( + headline: String, + subtitle: String?, + deviceFrame: DeviceFrame?, + sets: [GeneratedSet] + ) -> PersistedSets { + let entries = sets.map { set -> PersistedSets.Entry in + let shots = set.screenshots.compactMap { shot -> PersistedSets.Screenshot? in + guard let path = shot.imagePath, shot.image != nil else { return nil } + return PersistedSets.Screenshot( + captureLabel: shot.captureLabel, + imagePath: path, + sourceScreenshot: shot.sourceScreenshot, + headline: shot.headline, + subtitle: shot.subtitle, + tagline: shot.tagline, + appName: shot.appName + ) + } + return PersistedSets.Entry( + templateId: set.template.id, + templateName: set.template.name, + templateCategory: set.template.category, + paletteBackground: set.template.palette?.background, + screenshots: shots + ) + } + return PersistedSets( + formatVersion: PersistedSets.currentFormatVersion, + headline: headline, + subtitle: subtitle, + deviceFrameName: deviceFrame?.name, + createdAt: Date(), + entries: entries + ) + } +} diff --git a/src/services/appshots/DeviceFrameCompositor.swift b/src/services/appshots/DeviceFrameCompositor.swift new file mode 100644 index 0000000..4434301 --- /dev/null +++ b/src/services/appshots/DeviceFrameCompositor.swift @@ -0,0 +1,80 @@ +import Foundation +import AppKit +import CoreGraphics + +// Loads iPhone bezel PNGs + insets.json from the app bundle, and composites +// raw screenshots into the device's screen area, producing a PNG suitable +// for feeding into `asc app-shots templates apply`. + +struct DeviceFrameCompositor { + let frames: [DeviceFrame] + + init() { + self.frames = Self.loadFrames() + } + + /// Composite a screenshot onto the named device frame. Returns a path to the temp PNG, + /// or nil if resources are missing or drawing failed. + func composite(screenshotPath: String, device: DeviceFrame) -> String? { + guard let frameURL = Bundle.appResources.url(forResource: device.name, withExtension: "png"), + let frameImage = NSImage(contentsOf: frameURL), + let frameCG = frameImage.cgImage(forProposedRect: nil, context: nil, hints: nil), + let screenshotImage = NSImage(contentsOfFile: screenshotPath), + let screenshotCG = screenshotImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + + let fw = device.outputWidth + let fh = device.outputHeight + let ix = device.screenInsetX + let iy = device.screenInsetY + let sw = fw - ix * 2 + let sh = fh - iy * 2 + + guard let ctx = CGContext( + data: nil, width: fw, height: fh, + bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + let screenRect = CGRect(x: ix, y: fh - iy - sh, width: sw, height: sh) + let cornerRadius = CGFloat(fw) * 0.055 + + ctx.saveGState() + ctx.addPath(CGPath(roundedRect: screenRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)) + ctx.clip() + ctx.draw(screenshotCG, in: screenRect) + ctx.restoreGState() + + ctx.draw(frameCG, in: CGRect(x: 0, y: 0, width: fw, height: fh)) + + guard let composited = ctx.makeImage() else { return nil } + + let tmpPath = FileManager.default.temporaryDirectory + .appendingPathComponent("blitz-framed-\(UUID().uuidString).png").path + let bitmap = NSBitmapImageRep(cgImage: composited) + guard let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + try? pngData.write(to: URL(fileURLWithPath: tmpPath)) + return tmpPath + } + + static func loadFrames() -> [DeviceFrame] { + let bundle = Bundle.appResources + guard let url = bundle.url(forResource: "insets", withExtension: "json"), + let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Int]] else { + return [] + } + return json.compactMap { (name, values) in + guard bundle.url(forResource: name, withExtension: "png") != nil else { return nil } + return DeviceFrame( + name: name, + outputWidth: values["outputWidth"] ?? 0, + outputHeight: values["outputHeight"] ?? 0, + screenInsetX: values["screenInsetX"] ?? 0, + screenInsetY: values["screenInsetY"] ?? 0 + ) + }.sorted { $0.name < $1.name } + } +} diff --git a/src/views/AppTabView.swift b/src/views/AppTabView.swift index 5977188..8f96b8a 100644 --- a/src/views/AppTabView.swift +++ b/src/views/AppTabView.swift @@ -89,6 +89,8 @@ struct AppTabView: View { TestsView(appState: appState) case .icon: AssetsView(appState: appState) + case .appShots: + AppShotsView(appState: appState) } } } diff --git a/src/views/build/AppShotsView.swift b/src/views/build/AppShotsView.swift new file mode 100644 index 0000000..b280a47 --- /dev/null +++ b/src/views/build/AppShotsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// The App Shots tab. Two top-level views: +/// - `AppShotsHeroView` on first arrival (no captures, no generated sets) +/// - `AppShotsWorkspaceView` for everything else — a 3-column persistent canvas +/// where capture / generation / done states are all rendered in the same layout. +struct AppShotsView: View { + var appState: AppState + + @State private var manager = AppShotsFlowManager() + + private var projectId: String? { appState.activeProjectId } + private var projectName: String { appState.activeProject?.name ?? "Your App" } + private var bootedUDID: String? { appState.simulatorManager.bootedDeviceId } + + var body: some View { + ZStack { + AppShotsBackground() + content + } + .task { + await manager.bootstrap(projectId: projectId, projectName: projectName) + } + .onChange(of: projectId) { _, _ in + Task { await manager.bootstrap(projectId: projectId, projectName: projectName) } + } + } + + @ViewBuilder + private var content: some View { + switch manager.step { + case .hero: + AppShotsHeroView(hasProject: projectId != nil) { + manager.startBuilding() + } + case .capture, .generating, .done: + AppShotsWorkspaceView(manager: manager, bootedUDID: bootedUDID, projectName: projectName) + } + } +} \ No newline at end of file diff --git a/src/views/build/appshots/AppShotsAtoms.swift b/src/views/build/appshots/AppShotsAtoms.swift new file mode 100644 index 0000000..3a7122a --- /dev/null +++ b/src/views/build/appshots/AppShotsAtoms.swift @@ -0,0 +1,637 @@ +import SwiftUI + +// Shared atoms for the App Shots views. Adaptive — light & dark mode. + +// MARK: - Tokens + +enum AppShotsTokens { + // Two-surface system — matches the POC: + // canvas = the "board" — captures panel, sets canvas, inspector all share this + // cardSurface = elevated white surface — cards, inputs, capture rows + // Everything else (shadows, borders) distinguishes elevation. + + /// Board background — captures / canvas / inspector all use this. + /// Light gray in light mode; deep gray in dark mode. + static var canvasBackground: Color { Color(nsColor: .windowBackgroundColor) } + /// Back-compat alias; same as canvas so panels don't introduce a third tint. + static var panelBackground: Color { canvasBackground } + /// Elevated surface — cards, inputs, capture rows. White in light mode. + static var cardSurface: Color { Color(nsColor: .controlBackgroundColor) } + /// Alias for inset (form inputs, inset tiles). + static var insetBackground: Color { cardSurface } + /// Hairline rules. + static var separator: Color { Color(nsColor: .separatorColor) } + /// Subtle filled strokes (cards, dashed dropzones). + static var subtleStroke: Color { Color.primary.opacity(0.10) } +} + +// MARK: - Background + +struct AppShotsBackground: View { + var body: some View { + AppShotsTokens.canvasBackground.ignoresSafeArea() + } +} + +// MARK: - Section card + +struct AppShotsSectionCard: View { + @ViewBuilder var content: Content + var body: some View { + VStack(alignment: .leading, spacing: 12) { content } + .padding(16) + .background(RoundedRectangle(cornerRadius: 12).fill(AppShotsTokens.insetBackground)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppShotsTokens.subtleStroke)) + } +} + +// MARK: - Section label + +struct AppShotsLabel: View { + let text: String + var body: some View { + Text(text) + .font(.caption2) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.5) + } +} + +// MARK: - Toggle row + +struct AppShotsToggleRow: View { + let title: String + let hint: String + @Binding var isOn: Bool + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout.weight(.medium)) + Text(hint).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $isOn) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + } +} + +// MARK: - Set card +// +// Big multi-thumb card: up to 3 inline thumbs + "+N" overflow tile + meta row. +// Each thumb stretches via `flex: 1 1 0` equivalent so thumbs are large enough +// to read as real App Store screenshots, not icons. +struct AppShotsSetCard: View { + let set: GeneratedSet + var onOpen: (() -> Void)? = nil + + private static let inlineLimit = 3 + + var body: some View { + Button { onOpen?() } label: { + VStack(spacing: 14) { + thumbStrip + meta + } + .padding(16) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 14).fill(AppShotsTokens.cardSurface)) + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(AppShotsTokens.subtleStroke)) + .shadow(color: Color.black.opacity(0.06), radius: 4, y: 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: Thumb strip + + private var thumbStrip: some View { + let visibleCount = min(Self.inlineLimit, set.screenshots.count) + let visible = Array(set.screenshots.prefix(visibleCount)) + let overflow = max(0, set.screenshots.count - visibleCount) + return HStack(alignment: .top, spacing: 8) { + ForEach(Array(visible.enumerated()), id: \.element.id) { index, shot in + thumbnail( + shot: shot, + moreBadge: (index == visible.count - 1 && overflow > 0) ? "+\(overflow)" : nil + ) + .frame(maxWidth: .infinity) + } + } + } + + private func thumbnail(shot: GeneratedScreenshot, moreBadge: String?) -> some View { + ZStack(alignment: .topTrailing) { + // Always paint the template's palette gradient underneath — that's the card's identity. + LinearGradient(colors: [paletteStart, paletteEnd], startPoint: .topLeading, endPoint: .bottomTrailing) + + if let image = shot.image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if shot.error != nil { + failedOverlay + } + + if let moreBadge { + Text(moreBadge) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(.black.opacity(0.72))) + .padding(6) + } + } + .aspectRatio(9/19.5, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) + .help(shot.error.map { "Render failed — \($0)" } ?? "") + } + + private var failedOverlay: some View { + ZStack { + // Hatched amber — clearly "something went wrong" but still palette-contextual. + Rectangle().fill(Color(red: 1.0, green: 0.96, blue: 0.90)) + .overlay( + Rectangle().fill(LinearGradient( + stops: [.init(color: Color.orange.opacity(0.18), location: 0), + .init(color: .clear, location: 0.5), + .init(color: Color.orange.opacity(0.18), location: 1)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + ) + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.orange) + } + } + + + // MARK: Meta row + + private var meta: some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(set.template.name) + .font(.callout.weight(.semibold)) + .lineLimit(1) + Text("\(set.template.category.capitalized) · \(set.screenshots.count) shot\(set.screenshots.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 4) + statusBadge + } + .padding(.horizontal, 2) + } + + @ViewBuilder + private var statusBadge: some View { + let failed = set.screenshots.filter { $0.error != nil }.count + if failed > 0 { + Text("\(set.readyCount)/\(set.screenshots.count)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + } else if set.isReady { + HStack(spacing: 3) { + Image(systemName: "checkmark") + Text("ready") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } else { + Text("rendering…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: Palette + + private var paletteStart: Color { + Color(hex: set.template.palette?.background ?? "#4b5563") ?? .gray + } + private var paletteEnd: Color { + paletteStart.opacity(0.78) + } + /// Pick light or dark text based on palette luminance. + private var headlineColor: Color { + let lum = paletteLuminance + return lum < 0.6 ? .white : Color.black.opacity(0.85) + } + private var paletteLuminance: Double { + guard let hex = set.template.palette?.background, + let c = Color(hex: hex) else { return 0 } + let ns = NSColor(c).usingColorSpace(.deviceRGB) + guard let ns else { return 0 } + return 0.2126 * Double(ns.redComponent) + 0.7152 * Double(ns.greenComponent) + 0.0722 * Double(ns.blueComponent) + } +} + +/// Modal that shows every screenshot in a set at full size and lets the user +/// ship them straight to App Store Connect. +/// +/// Takes the manager + setId (not the set itself) so the sheet re-reads live state +/// after retries / regenerates. +struct AppShotsSetDetailSheet: View { + let manager: AppShotsFlowManager + let setId: String + let projectName: String + let onClose: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + /// Live lookup: the sheet re-reads from the manager each render so retries update in place. + /// Methods below assume this is non-nil — `body` guards first. + private var set: GeneratedSet { + manager.generated.first(where: { $0.id == setId }) + ?? GeneratedSet(id: setId, template: ASCManager.AppShotTemplate(id: setId, name: "—", category: "—", description: "", deviceCount: 1, palette: nil), headline: "", subtitle: nil, screenshots: []) + } + private var setExists: Bool { manager.generated.contains(where: { $0.id == setId }) } + + var body: some View { + Group { + if setExists { + VStack(spacing: 0) { + paletteStrip + header + scroller + footer + } + } else { + VStack(spacing: 12) { + Text("This set is no longer available.") + .font(.headline) + Button("Close") { onClose() } + } + .padding(40) + } + } + .frame(minWidth: 960, minHeight: 940) + .background(paper) + } + + // MARK: - Surface + // + // Editorial "paper" instead of system gray. Warm off-white in light, + // deep ink in dark — the screenshots get a proper surface to sit on. + + private var paper: some View { + (colorScheme == .dark + ? Color(red: 0.070, green: 0.070, blue: 0.085) + : Color(red: 0.980, green: 0.976, blue: 0.968) + ) + .ignoresSafeArea() + } + + /// 4-pt palette band at the very top — brand identity without colonising the canvas. + private var paletteStrip: some View { + Rectangle() + .fill(LinearGradient( + colors: [paletteColor, paletteColor.opacity(0.55), paletteColor], + startPoint: .leading, endPoint: .trailing)) + .frame(height: 4) + } + + // MARK: - Header — compact, single horizontal row + + private var header: some View { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(set.template.name) + .font(.system(size: 15, weight: .semibold)) + metaRow + } + Spacer(minLength: 12) + if let folder = folderPath { + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: folder) + } label: { Label("Show in Finder", systemImage: "folder") } + .buttonStyle(.bordered) + .controlSize(.small) + } + Button { + // Placeholder for the ASC upload flow — stub for now. + } label: { + Label("Upload to ASC", systemImage: "arrow.up.forward.circle.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(readyCount == 0) + + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) + .help("Close") + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .overlay(hairline, alignment: .bottom) + } + + private var metaRow: some View { + HStack(spacing: 6) { + Circle().fill(paletteColor).frame(width: 6, height: 6) + Text(set.template.category.capitalized) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + dot + Text("\(set.screenshots.count) shot\(set.screenshots.count == 1 ? "" : "s")") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + dot + Text("\u{201C}\(set.headline)\u{201D}") + .font(.system(size: 11.5)) + .italic() + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private var dot: some View { + Text("·").font(.system(size: 11.5)).foregroundStyle(.tertiary) + } + + // MARK: - Scroller + // + // App Store listings are horizontal — so is the preview. All N screenshots + // stay at the same height, user scrolls left-right. Feels like the store itself. + + private var scroller: some View { + GeometryReader { geo in + ScrollView(.horizontal, showsIndicators: true) { + HStack(alignment: .top, spacing: 22) { + ForEach(Array(set.screenshots.enumerated()), id: \.element.id) { index, shot in + shotCard(shot, index: index + 1, height: cardHeight(for: geo.size.height)) + } + } + .padding(.horizontal, 28) + .padding(.top, 28) + .padding(.bottom, 24) + } + } + } + + /// Reserve ~360pt below each phone for the meta row + the 4-field copy editor. + /// Keeps the editor fully visible instead of getting clipped at the bottom. + private func cardHeight(for height: CGFloat) -> CGFloat { + let reserved: CGFloat = 360 + let available = max(320, height - reserved) + return min(available, 540) + } + + private func shotCard(_ shot: GeneratedScreenshot, index: Int, height: CGFloat) -> some View { + // Match iPhone screenshot aspect (9:19.5) so there's no letterbox + // revealing the palette fill behind the image. + let width = height * 9 / 19.5 + return VStack(alignment: .leading, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 26).fill(paletteColor) + if let image = shot.image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 26)) + } else if shot.error != nil { + failedBadge + } + } + .frame(width: width, height: height) + .overlay(RoundedRectangle(cornerRadius: 26).strokeBorder(Color.primary.opacity(0.06))) + .shadow(color: paletteColor.opacity(0.40), radius: 32, y: 22) + .shadow(color: Color.black.opacity(0.14), radius: 5, y: 3) + + shotLabelRow(shot: shot, index: index, width: width) + + ShotCopyEditor( + manager: manager, + setId: setId, + shot: shot, + projectName: projectName + ) + .frame(width: width) + } + } + + private func shotLabelRow(shot: GeneratedScreenshot, index: Int, width: CGFloat) -> some View { + HStack(spacing: 8) { + Text(String(format: "%02d", index)) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(.primary.opacity(0.55)) + Text(shot.captureLabel) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary.opacity(0.80)) + .lineLimit(1) + Spacer(minLength: 4) + if let path = shot.imagePath, shot.error == nil { + iconButton(systemName: "eye", tooltip: "Preview") { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + iconButton(systemName: "folder", tooltip: "Show in Finder") { + NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") + } + } + } + .frame(width: width) + } + + private func iconButton(systemName: String, tooltip: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 10, weight: .medium)) + .frame(width: 22, height: 22) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(tooltip) + } + + private var failedBadge: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.orange) + Text("Render failed") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RoundedRectangle(cornerRadius: 22).fill(Color.orange.opacity(0.10))) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.up.forward.circle") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Text(footerText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer() + Button("Change locale") { + // Placeholder — future locale picker. + } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + .padding(.horizontal, 28) + .padding(.vertical, 14) + .overlay(hairline, alignment: .top) + } + + private var hairline: some View { + Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1) + } + + private var footerText: String { + let ready = readyCount + if ready == 0 { return "No renders ready yet — upload will enable once at least one shot finishes." } + return "Uploading sends these \(ready) PNG\(ready == 1 ? "" : "s") to the en-US locale in App Store Connect." + } + + // MARK: - Helpers + + private var readyCount: Int { + let shots = set.screenshots + return shots.filter { $0.image != nil }.count + } + + private var paletteColor: Color { + if let hex = set.template.palette?.background, let c = Color(hex: hex) { return c } + return Color.gray.opacity(0.3) + } + + private var folderPath: String? { + guard let path = set.screenshots.compactMap({ $0.imagePath }).first else { return nil } + return (path as NSString).deletingLastPathComponent + } +} + +// MARK: - Shot copy editor +// +// Inline headline/subtitle edit below each shot in the detail sheet. +// Direct manipulation: change the copy right under the thing it describes, +// hit Regenerate to re-render just that shot. + +struct ShotCopyEditor: View { + let manager: AppShotsFlowManager + let setId: String + let shot: GeneratedScreenshot + let projectName: String + + @State private var isApplying = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + field(label: "Headline", binding: headlineBinding, prompt: headlinePrompt) + field(label: "Subtitle · varies if blank", binding: subtitleBinding, prompt: "Optional") + field(label: "Tagline", binding: taglineBinding, prompt: "e.g. APP MANAGEMENT") + field(label: "App name", binding: appNameBinding, prompt: projectName.isEmpty ? "Optional" : projectName) + statusRow + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(AppShotsTokens.cardSurface)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppShotsTokens.subtleStroke)) + } + + private func field(label: String, binding: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + AppShotsLabel(text: label) + TextField("", text: binding, prompt: Text(prompt).font(.caption)) + .textFieldStyle(.plain) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(AppShotsTokens.canvasBackground)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppShotsTokens.subtleStroke)) + .onSubmit(apply) + } + } + + private var headlineBinding: Binding { + Binding( + get: { shot.headline }, + set: { manager.updateShotHeadline(setId: setId, screenshotId: shot.id, headline: $0) } + ) + } + private var subtitleBinding: Binding { + Binding( + get: { shot.subtitle }, + set: { manager.updateShotSubtitle(setId: setId, screenshotId: shot.id, subtitle: $0) } + ) + } + private var taglineBinding: Binding { + Binding( + get: { shot.tagline }, + set: { manager.updateShotTagline(setId: setId, screenshotId: shot.id, tagline: $0) } + ) + } + private var appNameBinding: Binding { + Binding( + get: { shot.appName }, + set: { manager.updateShotAppName(setId: setId, screenshotId: shot.id, appName: $0) } + ) + } + + @ViewBuilder + private var statusRow: some View { + HStack(spacing: 6) { + if isApplying { + ProgressView().controlSize(.mini) + Text("Applying…").font(.caption2).foregroundStyle(.secondary) + } else { + Image(systemName: "return").font(.caption2).foregroundStyle(.secondary) + Text("Press Return to re-render").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + } + } + + private func apply() { + guard !isApplying else { return } + Task { + isApplying = true + await manager.applyShotChanges( + setId: setId, + screenshotId: shot.id, + projectName: projectName + ) + isApplying = false + } + } + + private var headlinePrompt: String { + manager.defaultHeadline.isEmpty ? "Headline for this screen" : manager.defaultHeadline + } +} + +// MARK: - Hex helper + +extension Color { + init?(hex: String) { + var s = hex.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("#") { s.removeFirst() } + guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil } + self = Color( + red: Double((v >> 16) & 0xFF) / 255, + green: Double((v >> 8) & 0xFF) / 255, + blue: Double( v & 0xFF) / 255 + ) + } +} diff --git a/src/views/build/appshots/AppShotsHeroView.swift b/src/views/build/appshots/AppShotsHeroView.swift new file mode 100644 index 0000000..2bf8488 --- /dev/null +++ b/src/views/build/appshots/AppShotsHeroView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct AppShotsHeroView: View { + let hasProject: Bool + let onStart: () -> Void + + var body: some View { + VStack(spacing: 18) { + Spacer() + + HStack(spacing: 6) { + Image(systemName: "sparkle") + Text("No sets yet") + } + .font(.caption) + .padding(.horizontal, 12).padding(.vertical, 4) + .background(Capsule().fill(Color.accentColor.opacity(0.15))) + .overlay(Capsule().strokeBorder(Color.accentColor.opacity(0.35))) + .foregroundStyle(Color.accentColor) + + Text("Build your App Store screenshot sets") + .font(.system(size: 30, weight: .semibold)) + .multilineTextAlignment(.center) + + Text("Capture a handful of screens from your simulator, pick a headline — we'll lay them into 8 polished template sets you can ship.") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + + if hasProject { + Button(action: onStart) { + HStack(spacing: 8) { + Text("Start building") + Image(systemName: "arrow.right") + } + .fontWeight(.semibold) + .padding(.horizontal, 26).padding(.vertical, 13) + .frame(minWidth: 240) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.top, 6) + + Text("Takes about 1 minute · device frames included") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + noProjectHint + } + + Spacer() + + HStack(spacing: 12) { + tile(num: "01 CAPTURE", + title: "Screens from your sim", + body: "Use Capture or Record while you tap through your app. Uploading PNGs also works.") + tile(num: "02 FRAME & WRITE", + title: "Device frame + varied copy", + body: "Auto device-frame per template; subtitles vary so 8 sets actually look distinct.") + tile(num: "03 PERSISTENT", + title: "Your sets stay here", + body: "Come back anytime — the App Shots tab is always your sets for this project.") + } + .padding(.top, 16) + } + .padding(40) + .frame(maxWidth: 900) + } + + private var noProjectHint: some View { + VStack(spacing: 8) { + Image(systemName: "folder.badge.questionmark") + .font(.title) + .foregroundStyle(.secondary) + Text("Pick a project in the sidebar to start.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(20) + .frame(maxWidth: 420) + .background(RoundedRectangle(cornerRadius: 12).fill(AppShotsTokens.insetBackground)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppShotsTokens.subtleStroke)) + } + + private func tile(num: String, title: String, body: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(num).font(.caption2).foregroundStyle(.tertiary).tracking(0.5) + Text(title).font(.callout.weight(.semibold)) + Text(body).font(.caption).foregroundStyle(.secondary).lineLimit(3) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(AppShotsTokens.insetBackground)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppShotsTokens.subtleStroke)) + } +} diff --git a/src/views/build/appshots/AppShotsWorkspaceView.swift b/src/views/build/appshots/AppShotsWorkspaceView.swift new file mode 100644 index 0000000..d8e1601 --- /dev/null +++ b/src/views/build/appshots/AppShotsWorkspaceView.swift @@ -0,0 +1,641 @@ +import SwiftUI +import AppKit + +/// 3-column persistent workspace: Captures · Sets · Inspector. +/// Adaptive — light & dark mode via system semantic colors. +struct AppShotsWorkspaceView: View { + var manager: AppShotsFlowManager + let bootedUDID: String? + let projectName: String + + @State private var openSet: GeneratedSet? + + var body: some View { + VStack(spacing: 0) { + toolbar + if manager.step == .generating { progressStrip } + HStack(spacing: 0) { + CapturesPanel(manager: manager, bootedUDID: bootedUDID) + .frame(width: 240) + Divider() + SetsPanel(manager: manager) { set in openSet = set } + .frame(maxWidth: .infinity) + Divider() + InspectorPanel(manager: manager, projectName: projectName) + .frame(width: 260) + } + } + .sheet(item: $openSet) { set in + AppShotsSetDetailSheet( + manager: manager, + setId: set.id, + projectName: projectName, + onClose: { openSet = nil } + ) + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + HStack(spacing: 6) { + Text(projectName).font(.callout.weight(.semibold)) + Text("·").foregroundStyle(.tertiary) + Text("App Shots").font(.callout).foregroundStyle(.secondary) + } + statusPill + Spacer() + if manager.step == .done { + Button { + revealFolder() + } label: { + Label("Show folder", systemImage: "folder") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + Button { + manager.resetToHero() + } label: { + Label("Reset", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(AppShotsTokens.panelBackground) + .overlay(Divider(), alignment: .bottom) + } + + private var statusPill: some View { + let (text, color): (String, Color) = { + switch manager.step { + case .hero, .capture: + let n = manager.captures.count + return ("Ready · \(n) capture\(n == 1 ? "" : "s")", .secondary) + case .generating: + return ("Generating · \(manager.totalRendersDone) of \(manager.totalRendersExpected)", .accentColor) + case .done: + let done = manager.generated.filter { $0.isReady }.count + return ("\(done) of \(manager.generated.count) sets ready", .green) + } + }() + return HStack(spacing: 5) { + Circle().fill(color).frame(width: 6, height: 6) + Text(text).font(.caption) + } + .padding(.horizontal, 9) + .padding(.vertical, 3) + .background(Capsule().fill(color.opacity(0.12))) + .overlay(Capsule().strokeBorder(color.opacity(0.3))) + .foregroundStyle(color) + } + + private var progressStrip: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(Color.primary.opacity(0.06)) + Rectangle() + .fill(LinearGradient(colors: [Color.accentColor, Color.purple], startPoint: .leading, endPoint: .trailing)) + .frame(width: progressWidth(in: geo.size.width)) + } + } + .frame(height: 2) + } + + private func progressWidth(in total: CGFloat) -> CGFloat { + let expected = manager.totalRendersExpected + guard expected > 0 else { return 0 } + return total * CGFloat(manager.totalRendersDone) / CGFloat(expected) + } + + private func revealFolder() { + let firstPath = manager.generated + .flatMap { $0.screenshots } + .compactMap { $0.imagePath } + .first + guard let path = firstPath else { return } + let folder = (path as NSString).deletingLastPathComponent + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: folder) + } +} + +// MARK: - Captures panel + +private struct CapturesPanel: View { + var manager: AppShotsFlowManager + let bootedUDID: String? + + var body: some View { + VStack(spacing: 0) { + PanelHeader(title: "Captures", trailing: headerTrailing) + actions + list + } + .frame(maxHeight: .infinity) + .background(AppShotsTokens.panelBackground) + } + + private var headerTrailing: String { + let active = manager.includedCaptures.count + let total = manager.captures.count + return total == 0 ? "0" : "\(active) of \(total) active" + } + + private var actions: some View { + VStack(spacing: 6) { + Button { + Task { await manager.captureOnce(bootedUDID: bootedUDID) } + } label: { + Label(captureLabel, systemImage: "camera") + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(manager.isCapturing || manager.isRecording || bootedUDID == nil) + + Button { + manager.toggleRecording(bootedUDID: bootedUDID) + } label: { + HStack { + Image(systemName: manager.isRecording ? "stop.circle.fill" : "record.circle") + .foregroundStyle(manager.isRecording ? .red : .primary) + Text(manager.isRecording ? "Stop recording" : "Record flow") + Spacer() + } + .padding(.vertical, 4).padding(.horizontal, 4) + } + .buttonStyle(.bordered) + .disabled(bootedUDID == nil) + + Button { + manager.importFiles() + } label: { + Label("Upload PNGs", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4).padding(.horizontal, 4) + } + .buttonStyle(.bordered) + + footnote + } + .padding(.horizontal, 14) + .padding(.bottom, 12) + } + + private var captureLabel: String { + manager.isCapturing && !manager.isRecording ? "Capturing…" : "Capture screen" + } + + @ViewBuilder + private var footnote: some View { + if let error = manager.captureError { + Text(error).font(.caption).foregroundStyle(.orange) + .padding(.top, 2) + } else if manager.isRecording { + Text("Recording — tap around your sim. A frame every 2s, dups skipped.") + .font(.caption).foregroundStyle(.orange) + .padding(.top, 2) + } else if bootedUDID == nil { + Text("No booted simulator — boot one in Simulator tab, or upload PNGs.") + .font(.caption).foregroundStyle(.secondary) + .padding(.top, 2) + } else { + Text("Tap Capture while navigating your app. Uncheck any blank/wrong screen to skip it.") + .font(.caption).foregroundStyle(.secondary) + .padding(.top, 2) + } + } + + private var list: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + if manager.captures.isEmpty { + emptyState + } else { + ForEach(Array(manager.captures.enumerated()), id: \.element.id) { index, shot in + CaptureRow( + index: index + 1, + shot: shot, + defaultHeadline: manager.defaultHeadline, + onToggle: { manager.toggleCaptureInclusion(id: shot.id) }, + onRemove: { manager.removeCapture(id: shot.id) }, + onHeadlineChange: { manager.updateCaptureHeadline(id: shot.id, headline: $0) } + ) + } + if manager.blankWarningCount > 0 { + blankWarnBanner + } + } + } + .padding(.horizontal, 14) + .padding(.bottom, 14) + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "arrow.up") + .font(.title3) + .foregroundStyle(.tertiary) + Text("No captures yet — start with **Capture screen** above.") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding(.vertical, 24) + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])) + .foregroundStyle(AppShotsTokens.subtleStroke) + ) + } + + private var blankWarnBanner: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + let n = manager.blankWarningCount + Text("\(n) capture\(n == 1 ? "" : "s") look blank. Auto-excluded — tick the box to include.") + .font(.caption) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.orange.opacity(0.1))) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color.orange.opacity(0.35))) + } +} + +private struct CaptureRow: View { + let index: Int + let shot: CapturedShot + let defaultHeadline: String + let onToggle: () -> Void + let onRemove: () -> Void + let onHeadlineChange: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + topRow + headlineField + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(AppShotsTokens.cardSurface)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppShotsTokens.subtleStroke)) + .opacity(shot.included ? 1 : 0.65) + } + + private var topRow: some View { + HStack(spacing: 8) { + Button(action: onToggle) { + RoundedRectangle(cornerRadius: 4) + .fill(shot.included ? Color.accentColor : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(shot.included ? Color.accentColor : AppShotsTokens.subtleStroke, lineWidth: 1.5) + ) + .overlay( + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .opacity(shot.included ? 1 : 0) + ) + .frame(width: 16, height: 16) + } + .buttonStyle(.plain) + .help(shot.included ? "Include in generation" : "Skip during generation") + + Image(nsImage: shot.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 32, height: 64) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(AppShotsTokens.subtleStroke)) + .saturation(shot.included ? 1 : 0.2) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text("Screen \(index)") + .font(.caption.weight(.semibold)) + if let warn = shot.warning { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + .help(warn) + } + } + Text(byteSize) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(4) + } + .buttonStyle(.plain) + .help("Remove") + } + } + + private var headlineField: some View { + TextField( + "", + text: Binding( + get: { shot.headline }, + set: { onHeadlineChange($0) } + ), + prompt: Text(placeholder).font(.caption) + ) + .textFieldStyle(.plain) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(AppShotsTokens.canvasBackground)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppShotsTokens.subtleStroke)) + } + + /// Placeholder cue: show the fallback that'll be used when this field is empty. + private var placeholder: String { + if !defaultHeadline.isEmpty { return defaultHeadline } + return "Headline for this screen" + } + + private var byteSize: String { + let attrs = try? FileManager.default.attributesOfItem(atPath: shot.path) + let size = (attrs?[.size] as? NSNumber)?.intValue ?? 0 + return ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + } +} + +// MARK: - Sets panel (center) + +private struct SetsPanel: View { + var manager: AppShotsFlowManager + var onOpenSet: (GeneratedSet) -> Void + + var body: some View { + VStack(spacing: 0) { + PanelHeader(title: "Screenshot sets", trailing: trailingText) + ScrollView { + Group { + if manager.generated.isEmpty { + emptyState + } else { + liveGrid + } + } + .padding(18) + } + } + .frame(maxHeight: .infinity) + .background(AppShotsTokens.canvasBackground) + } + + private var trailingText: String { + let total = manager.generated.isEmpty ? 8 : manager.generated.count + let done = manager.generated.filter { $0.isReady }.count + return "\(done) of \(total) sets" + } + + private var emptyState: some View { + VStack(spacing: 14) { + // Accent-tinted icon tile + RoundedRectangle(cornerRadius: 18) + .fill(LinearGradient( + colors: [Color.accentColor.opacity(0.14), Color.purple.opacity(0.14)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 72, height: 72) + .overlay( + Image(systemName: "sparkles") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(Color.accentColor) + ) + + Text("Your 8 sets will appear here") + .font(.title3.weight(.semibold)) + Text("Capture screens on the left and pick a headline on the right — we'll render 8 polished template sets in about a minute.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + Divider().frame(maxWidth: 420).padding(.top, 4) + + HStack(spacing: 8) { + stepTile(num: "01", title: "Capture", body: "Screens from your simulator.") + stepTile(num: "02", title: "Write", body: "One headline for all sets.") + stepTile(num: "03", title: "Generate", body: "8 ready-to-ship sets.") + } + .frame(maxWidth: 460) + } + .padding(32) + .frame(maxWidth: 560) + .background(RoundedRectangle(cornerRadius: 16).fill(AppShotsTokens.panelBackground)) + .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(AppShotsTokens.subtleStroke)) + .shadow(color: Color.black.opacity(0.05), radius: 3, y: 1) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } + + private func stepTile(num: String, title: String, body: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(num) + .font(.caption2.weight(.bold)) + .tracking(0.5) + .foregroundStyle(.tertiary) + Text(title) + .font(.caption.weight(.semibold)) + Text(body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(AppShotsTokens.insetBackground)) + } + + private var liveGrid: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 320), spacing: 14)], + spacing: 16 + ) { + ForEach(manager.generated) { set in + AppShotsSetCard(set: set) { + if manager.step == .done { onOpenSet(set) } + } + } + } + } +} + +// MARK: - Inspector (right) + +private struct InspectorPanel: View { + var manager: AppShotsFlowManager + let projectName: String + + var body: some View { + VStack(spacing: 0) { + PanelHeader(title: "Inspector", trailing: nil) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + headlineSection + Divider() + frameSection + } + .padding(.horizontal, 14) + .padding(.bottom, 14) + } + generateFooter + } + .frame(maxHeight: .infinity) + .background(AppShotsTokens.panelBackground) + } + + private var headlineSection: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + AppShotsLabel(text: "Default Headline") + TextField("", text: Binding( + get: { manager.defaultHeadline }, + set: { manager.defaultHeadline = $0 } + ), prompt: Text(projectName)) + .textFieldStyle(.roundedBorder) + Text("Used for any screen whose own headline is blank.") + .font(.caption2) + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 6) { + AppShotsLabel(text: "Default Subtitle · varies if blank") + TextField("", text: Binding( + get: { manager.defaultSubtitle }, + set: { manager.defaultSubtitle = $0 } + ), prompt: Text("Optional")) + .textFieldStyle(.roundedBorder) + } + } + } + + private var frameSection: some View { + VStack(alignment: .leading, spacing: 12) { + AppShotsToggleRow( + title: "Apply device frame", + hint: "Wrap each capture in an iPhone bezel.", + isOn: Binding( + get: { manager.useFrame }, + set: { manager.useFrame = $0 } + ) + ) + if manager.useFrame && !manager.availableFrames.isEmpty { + VStack(alignment: .leading, spacing: 6) { + AppShotsLabel(text: "Device") + Picker("", selection: Binding( + get: { manager.selectedFrameName }, + set: { manager.selectedFrameName = $0 } + )) { + ForEach(manager.availableFrames, id: \.name) { frame in + Text(frame.name).tag(frame.name) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } + } + } + + private var generateFooter: some View { + VStack(spacing: 8) { + primaryButton + Text(generateHint) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .padding(14) + .overlay(Divider(), alignment: .top) + } + + @ViewBuilder + private var primaryButton: some View { + // Behavior depends on state. In .done we don't have captures in memory, + // so "Regenerate" can't reuse them — we ship the user back to the capture step. + if manager.step == .done && !manager.generated.isEmpty { + Button { + manager.regenerate() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + Text("New generation") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .controlSize(.large) + } else { + Button { + Task { await manager.generate(projectName: projectName) } + } label: { + HStack(spacing: 6) { + Image(systemName: "sparkles") + Text(manager.step == .generating ? "Generating…" : "Generate 8 sets") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!manager.canGenerate || manager.step == .generating) + } + } + + private var generateHint: String { + if manager.step == .done && !manager.generated.isEmpty { + return "Capture new screens, then generate again." + } + if manager.step == .generating { return "Hold tight — rendering all templates in parallel." } + if manager.includedCaptures.isEmpty && !manager.captures.isEmpty { + return "All captures excluded — tick at least one to generate." + } + if manager.captures.isEmpty { return "Add at least one capture to start." } + let n = manager.includedCaptures.count + return "Will render 8 templates × \(n) capture\(n == 1 ? "" : "s") = \(n * 8) screenshots." + } +} + +// MARK: - Shared + +private struct PanelHeader: View { + let title: String + let trailing: String? + + var body: some View { + HStack { + Text(title) + .font(.caption2.weight(.semibold)) + .tracking(0.5) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + if let trailing { + Text(trailing) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 8) + } +}