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>
218 lines
7 KiB
Swift
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
|