mana-swift-ui/Sources/ManaComponentsUI/ManaJobStatusView.swift
Till JS a8621c3ef5 ManaComponentsUI: ManaJobStatusView — Wartezeit-Anzeige für GPU-Jobs
Natives Pendant zu @mana/shared-ui <JobStatus>. + Texthelfer
ManaJobStatusText (9 Tests im neuen Target ManaComponentsUITests).
CHANGELOG trägt zusätzlich den vorbestehenden AutoFill-[Unreleased]-
Eintrag (fremde Arbeit, nur Doku-Text).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:58:50 +02:00

218 lines
7 KiB
Swift

import ManaTokens
import SwiftUI
/// Größe der ``ManaJobStatusView``.
public enum ManaJobStatusSize: Sendable {
case compact
case regular
}
/// Ehrliche Inline-Anzeige für einen lang laufenden KI-/GPU-Job
/// (Stem-Separation, Musik-Generierung, Transkription …).
///
/// Sichtbares Gegenstück zum GPU-Broker, der die geteilte GPU
/// serialisiert: die dadurch mögliche Wartezeit wird benannt
/// („läuft seit …", „dauert meist … / bei Andrang bis zu X min",
/// optional „Position N"), statt sie zu verstecken. Nicht-modal,
/// gedacht inline in der jeweiligen App-Oberfläche.
///
/// Der Spinner nutzt `ProgressView`, respektiert also Reduce-Motion
/// automatisch. Farben kommen aus dem injizierten `manaTheme`.
///
/// ```swift
/// ManaJobStatusView(
/// state: .running,
/// elapsedSeconds: 42,
/// typicalSeconds: 90,
/// maxMinutes: 10
/// )
/// ```
public struct ManaJobStatusView: View {
@Environment(\.manaTheme) private var theme
private let state: ManaJobState
private let label: String?
private let typicalSeconds: Int?
private let maxMinutes: Int?
private let elapsedSeconds: Int?
private let queuePosition: Int?
private let error: String?
private let size: ManaJobStatusSize
private let cancelLabel: String
private let onCancel: (() -> Void)?
/// Erstellt eine Job-Status-Anzeige.
/// - Parameters:
/// - state: Lebenszyklus des Jobs.
/// - label: Eigene Titelzeile statt der Default-Texte.
/// - typicalSeconds: Übliche Dauer → „dauert meist …".
/// - maxMinutes: Obergrenze bei Andrang → „bei Andrang bis zu …".
/// - elapsedSeconds: Sekunden seit Start → „läuft seit …".
/// - queuePosition: 1-basierte Position in der Warteschlange.
/// - error: Fehlertext im `.failed`-Zustand.
/// - size: `.regular` (Default) oder `.compact`.
/// - cancelLabel: Beschriftung des Abbrechen-Buttons.
/// - onCancel: Falls gesetzt und Job `.queued`/`.running` →
/// zeigt einen Abbrechen-Button.
public init(
state: ManaJobState,
label: String? = nil,
typicalSeconds: Int? = nil,
maxMinutes: Int? = nil,
elapsedSeconds: Int? = nil,
queuePosition: Int? = nil,
error: String? = nil,
size: ManaJobStatusSize = .regular,
cancelLabel: String = "Abbrechen",
onCancel: (() -> Void)? = nil
) {
self.state = state
self.label = label
self.typicalSeconds = typicalSeconds
self.maxMinutes = maxMinutes
self.elapsedSeconds = elapsedSeconds
self.queuePosition = queuePosition
self.error = error
self.size = size
self.cancelLabel = cancelLabel
self.onCancel = onCancel
}
public var body: some View {
HStack(spacing: Spacing.sm) {
icon
.frame(width: 22, alignment: .center)
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(titleFont)
.fontWeight(.medium)
.foregroundStyle(titleColor)
if let sub, !sub.isEmpty {
Text(sub)
.font(subFont)
.foregroundStyle(theme.colors.mutedForeground)
}
}
if showsCancel {
Spacer(minLength: Spacing.xs)
Button(cancelLabel) { onCancel?() }
.font(subFont)
.buttonStyle(.plain)
.foregroundStyle(theme.colors.mutedForeground)
}
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.colors.surface)
.clipShape(RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
.strokeBorder(theme.colors.border, lineWidth: 1)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityText)
}
// MARK: - Icon
@ViewBuilder
private var icon: some View {
switch state {
case .running:
ProgressView()
.controlSize(.small)
.tint(theme.colors.primary)
case .queued:
Image(systemName: "clock")
.foregroundStyle(theme.colors.mutedForeground)
case .done:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(theme.colors.success)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(theme.colors.error)
}
}
// MARK: - Text
private var title: String {
label ?? ManaJobStatusText.defaultTitle(state)
}
private var estimate: String {
switch state {
case .queued, .running:
return ManaJobStatusText.formatEstimate(
typicalSeconds: typicalSeconds,
maxMinutes: maxMinutes
)
case .done, .failed:
return ""
}
}
private var sub: String? {
var parts: [String] = []
if state == .queued, let pos = queuePosition, pos > 0 {
parts.append("Position \(pos)")
}
if state == .running, let elapsed = elapsedSeconds {
parts.append("läuft seit \(ManaJobStatusText.formatElapsed(elapsed))")
}
if !estimate.isEmpty { parts.append(estimate) }
if state == .failed, let error, !error.isEmpty { parts.append(error) }
let joined = parts.joined(separator: " · ")
return joined.isEmpty ? nil : joined
}
private var accessibilityText: String {
if let sub { return "\(title). \(sub)" }
return title
}
// MARK: - Styling
private var showsCancel: Bool {
onCancel != nil && (state == .queued || state == .running)
}
private var titleColor: Color {
state == .failed ? theme.colors.error : theme.colors.foreground
}
private var titleFont: Font {
size == .compact ? .subheadline : .callout
}
private var subFont: Font {
size == .compact ? .caption2 : .footnote
}
private var padding: CGFloat {
size == .compact ? Spacing.sm : Spacing.md
}
}
#if DEBUG
#Preview {
VStack(spacing: Spacing.sm) {
ManaJobStatusView(state: .queued, typicalSeconds: 90, maxMinutes: 10, queuePosition: 2)
ManaJobStatusView(state: .running, typicalSeconds: 90, maxMinutes: 10, elapsedSeconds: 42)
ManaJobStatusView(state: .done, label: "Stems sind fertig")
ManaJobStatusView(state: .failed, error: "Konnte Audio nicht laden")
ManaJobStatusView(
state: .running,
label: "Backing-Track wird erzeugt",
typicalSeconds: 180,
maxMinutes: 10,
elapsedSeconds: 135,
size: .compact,
onCancel: {}
)
}
.padding()
}
#endif