blitztext-app-linux/BlitztextMac/Features/MenuBar/MenuBarView.swift
2026-05-23 07:28:46 +02:00

1025 lines
35 KiB
Swift

import SwiftUI
struct MenuBarView: View {
@Bindable var appState: AppState
var body: some View {
VStack(spacing: 0) {
switch appState.page {
case .main:
mainPage
case .onboarding:
onboardingPage
case .settings:
settingsPage
case .workflow:
workflowPage
}
}
.frame(width: 340)
.animation(.easeInOut(duration: 0.2), value: appState.page)
}
// MARK: - Main Page
private var mainPage: some View {
VStack(spacing: 0) {
// Header
VStack(spacing: 0) {
// Top bar
HStack {
HStack(spacing: 6) {
Text("Blitztext")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Text("macOS Preview")
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.quaternary)
}
Spacer()
Button {
appState.page = .settings
} label: {
ZStack(alignment: .topTrailing) {
Image(systemName: "gear")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.tertiary)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.00001)) // hit target
)
.contentShape(Rectangle())
if !appState.accessibilityPermissionGranted {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
.offset(x: -4, y: 4)
}
}
}
.buttonStyle(SubtleButtonStyle())
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
// Status area
if appState.isConfigured {
configuredHeader
} else {
unconfiguredHeader
}
}
.padding(.bottom, 16)
.background(
Color(nsColor: .controlBackgroundColor).opacity(0.5)
)
if BlitztextInstallLocationService.shouldOfferMoveToApplications {
installHintBanner
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 6)
}
transcriptionModePanel
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, appState.accessibilityPermissionGranted ? 6 : 4)
if !appState.accessibilityPermissionGranted {
accessibilityHintBanner
.padding(.horizontal, 16)
.padding(.bottom, 6)
}
// Workflow list
VStack(spacing: 0) {
ForEach(WorkflowType.mainMenuCases) { type in
let enabled = appState.isWorkflowAvailable(type)
WorkflowRowView(
type: type,
enabled: enabled,
customName: appState.displayName(for: type),
subtitle: appState.workflowSubtitle(for: type)
) {
appState.startWorkflow(type)
}
}
}
.padding(.vertical, 2)
appFooter
}
}
private var transcriptionModePanel: some View {
let modelOptions = LocalTranscriptionService.modelOptions()
let selectedModelInstalled = appState.selectedLocalModelIsInstalled
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: appState.appSettings.secureLocalModeEnabled ? "lock.shield.fill" : "network")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(appState.appSettings.secureLocalModeEnabled ? .green : .blue)
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(appState.appSettings.secureLocalModeEnabled ? "Sicherer lokaler Modus" : "Online Whisper")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text(modePanelSubtitle(selectedModelInstalled: selectedModelInstalled))
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
Toggle("", isOn: Binding(
get: { appState.appSettings.secureLocalModeEnabled },
set: { newValue in
if newValue {
appState.enableSecureLocalMode()
} else {
appState.appSettings.secureLocalModeEnabled = false
}
}
))
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.small)
.disabled(appState.isDownloadingLocalModel)
}
if appState.appSettings.secureLocalModeEnabled {
HStack(spacing: 8) {
Text("Modell")
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
Picker("", selection: Binding(
get: { appState.selectedLocalModelName },
set: { appState.appSettings.selectedLocalTranscriptionModelName = $0 }
)) {
ForEach(modelOptions) { model in
Text(model.shortDisplayName).tag(model.id)
}
}
.labelsHidden()
.frame(maxWidth: .infinity)
.controlSize(.small)
.disabled(appState.isDownloadingLocalModel)
}
if let progress = appState.localModelDownloadProgress {
VStack(alignment: .leading, spacing: 4) {
ProgressView(value: progress)
Text(appState.localModelDownloadStatusText ?? "Modell wird geladen...")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
} else if !selectedModelInstalled {
Button(appState.localModelDownloadButtonTitle) {
appState.installSelectedLocalModel()
}
.controlSize(.small)
}
if let errorText = appState.localModelDownloadErrorText {
Text(errorText)
.font(.system(size: 10.5))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.primary.opacity(0.035))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5)
)
}
private func modePanelSubtitle(selectedModelInstalled: Bool) -> String {
if appState.appSettings.secureLocalModeEnabled {
if appState.isDownloadingLocalModel {
return appState.localModelDownloadStatusText ?? "Lokales Modell wird geladen."
}
if selectedModelInstalled {
return "Lokal mit \(appState.selectedLocalModelDisplayName)."
}
return "\(appState.selectedLocalModelDisplayName) ist noch nicht installiert."
}
return "Blitztext nutzt gerade die OpenAI-Transkription."
}
private var accessibilityHintBanner: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "hand.raised.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.orange)
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 3) {
Text("Einfügen braucht Bedienungshilfen.")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text("Nach Updates kann macOS die Freigabe neu verlangen.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
Button("Öffnen") {
appState.requestAccessibilityPermission()
}
.font(.system(size: 10.5, weight: .medium))
.buttonStyle(SubtleButtonStyle())
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5)
)
}
private var configuredHeader: some View {
HStack(spacing: 6) {
Circle()
.fill(.green)
.frame(width: 7, height: 7)
Text("Bereit")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
}
}
private var installHintBanner: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "externaldrive.badge.plus")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.orange)
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 3) {
Text("Für sauberen Anmeldestart nach /Applications verschieben.")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text("Sonst entstehen leichter doppelte Login-Items oder uneinheitliche Updates.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
Button("Prüfen") {
appState.page = .settings
}
.font(.system(size: 10.5, weight: .medium))
.buttonStyle(SubtleButtonStyle())
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5)
)
}
private var onboardingPage: some View {
VStack(spacing: 0) {
HStack {
Text("Willkommen bei Blitztext")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Button("Später") {
appState.page = .main
}
.font(.system(size: 11, weight: .medium))
.buttonStyle(SubtleButtonStyle())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
.background(
Color(nsColor: .controlBackgroundColor).opacity(0.5)
)
Divider()
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: 42, height: 42)
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
}
VStack(alignment: .leading, spacing: 3) {
Text("Einmal einrichten, dann direkt loslegen.")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
Text("Eigenen OpenAI API Key eintragen. Danach sprechen und einfügen.")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 10) {
if BlitztextInstallLocationService.shouldOfferMoveToApplications {
onboardingInstallCard
}
onboardingStep(number: "1", title: "OpenAI Key speichern", detail: "Öffne die Einstellungen und trage deinen eigenen OpenAI API Key ein.")
onboardingStep(number: "2", title: "Berechtigungen erlauben", detail: "Mikrofon und Bedienungshilfen für das Einfügen freigeben.")
onboardingStep(number: "3", title: "Workflow wählen", detail: "Blitztext oder einen der Verbesserer-Workflows direkt aus der Menüleiste starten.")
}
HStack(spacing: 8) {
Button {
appState.page = .settings
} label: {
Text("Jetzt einrichten")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.primary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
}
.buttonStyle(SubtleButtonStyle())
Text("Du findest alles später im Zahnrad.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
Spacer(minLength: 0)
appFooter
}
}
private var unconfiguredHeader: some View {
VStack(spacing: 10) {
ZStack {
Circle()
.fill(Color.orange.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.orange)
}
VStack(spacing: 4) {
Text("Einrichtung n\u{00F6}tig")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
Text("\u{00D6}ffne die Einstellungen und hinterlege deine Zugangsdaten, um loszulegen.")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 20)
}
Button {
appState.page = .settings
} label: {
Text("Einstellungen \u{00F6}ffnen")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.padding(.horizontal, 20)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.primary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
}
.buttonStyle(SubtleButtonStyle())
}
}
private func onboardingStep(number: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Text(number)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 18, height: 18)
.background(
Circle()
.fill(Color.primary.opacity(0.05))
)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text(detail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var onboardingInstallCard: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "arrow.down.app")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.orange)
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 4) {
Text("Lege Blitztext zuerst nach /Applications.")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text("Das hält Anmeldestart, spätere Updates und das Entfernen sauber auf einer einzigen App-Kopie.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5)
)
}
// MARK: - Settings Page
private var settingsPage: some View {
VStack(spacing: 0) {
// Header bar
HStack {
Button {
appState.page = .main
} label: {
HStack(spacing: 3) {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .semibold))
Text("Zur\u{00FC}ck")
.font(.system(size: 12))
}
.foregroundStyle(.secondary)
}
.buttonStyle(SubtleButtonStyle())
Spacer()
Text("Einstellungen")
.font(.system(size: 12, weight: .semibold))
Spacer()
settingsQuickAction
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
SettingsContentView(appState: appState)
Spacer(minLength: 0)
appFooter
}
}
@ViewBuilder
private var settingsQuickAction: some View {
if !appState.accessibilityPermissionGranted {
Button {
appState.requestAccessibilityPermission()
} label: {
HStack(spacing: 4) {
Image(systemName: "hand.raised")
.font(.system(size: 10, weight: .semibold))
Text("Rechte")
.font(.system(size: 12))
}
.foregroundStyle(.orange)
}
.buttonStyle(SubtleButtonStyle())
} else {
Color.clear.frame(width: 58, height: 18)
}
}
// MARK: - Workflow Page
private var workflowPage: some View {
VStack(spacing: 0) {
if let workflow = appState.activeWorkflow {
// Header bar
HStack {
Button {
appState.resetCurrentWorkflow()
} label: {
HStack(spacing: 3) {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .semibold))
Text("Zur\u{00FC}ck")
.font(.system(size: 12))
}
.foregroundStyle(.secondary)
}
.buttonStyle(SubtleButtonStyle())
Spacer()
HStack(spacing: 5) {
Image(systemName: workflow.type.icon)
.font(.system(size: 11))
.foregroundStyle(workflowIconColor(workflow.type))
Text(appState.displayName(for: workflow.type))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
// Content
switch workflow.type {
case .transcription, .localTranscription:
if let w = workflow as? TranscriptionWorkflow {
TranscriptionActiveView(workflow: w)
}
case .textImprover:
if let w = workflow as? TextImprovementWorkflow {
TextImproverActiveView(workflow: w)
}
case .dampfAblassen:
if let w = workflow as? DampfAblassenWorkflow {
DampfAblassenActiveView(workflow: w)
}
case .emojiText:
if let w = workflow as? EmojiTextWorkflow {
EmojiTextActiveView(workflow: w)
}
}
Spacer(minLength: 0)
appFooter
}
}
}
private var appFooter: some View {
HStack {
Spacer()
Button("Beenden") {
NSApplication.shared.terminate(nil)
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.quaternary)
.buttonStyle(SubtleButtonStyle())
Spacer()
}
.padding(.vertical, 8)
}
private func workflowIconColor(_ type: WorkflowType) -> Color {
switch type {
case .transcription: return .blue
case .localTranscription: return .green
case .textImprover: return .purple
case .dampfAblassen: return .orange
case .emojiText: return .cyan
}
}
}
// MARK: - Subtle Button Style
struct SubtleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.5 : 1.0)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
}
}
// MARK: - Transcription Active View
struct TranscriptionActiveView: View {
@Bindable var workflow: TranscriptionWorkflow
var body: some View {
VStack(spacing: 0) {
switch workflow.phase {
case .idle, .running:
if workflow.isRecording {
recordingView(onStop: { workflow.stop() })
} else {
processingView(message: "Wird transkribiert \u{2026}")
}
case .done(let text):
autoPasteView(text: text)
case .error(let msg):
errorView(message: msg) {
workflow.reset()
workflow.start()
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
@ViewBuilder
private func recordingView(onStop: @escaping () -> Void) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
WaveformView(audioLevel: workflow.audioLevel, isRecording: true)
.frame(height: 44)
.padding(.horizontal, 24)
// Monochrome stop button
Button(action: onStop) {
ZStack {
Circle()
.strokeBorder(.primary.opacity(0.2), lineWidth: 1.5)
.frame(width: 44, height: 44)
RoundedRectangle(cornerRadius: 3)
.fill(.primary.opacity(0.7))
.frame(width: 14, height: 14)
}
}
.buttonStyle(.plain)
Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer().frame(height: 8)
}
}
}
// MARK: - Text Improver Active View
struct TextImproverActiveView: View {
@Bindable var workflow: TextImprovementWorkflow
var body: some View {
VStack(spacing: 0) {
switch workflow.phase {
case .idle, .running:
if workflow.isRecording {
recordingView(onStop: { workflow.stop() })
} else {
VStack(spacing: 12) {
Spacer().frame(height: 24)
ProgressView()
.scaleEffect(0.7)
.controlSize(.small)
if case .running(let msg) = workflow.phase {
Text(msg)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
Spacer().frame(height: 24)
}
}
case .done(let text):
autoPasteView(text: text)
case .error(let msg):
errorView(message: msg) {
workflow.reset()
workflow.start()
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
@ViewBuilder
private func recordingView(onStop: @escaping () -> Void) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
WaveformView(audioLevel: workflow.audioLevel, isRecording: true)
.frame(height: 44)
.padding(.horizontal, 24)
// Monochrome stop button
Button(action: onStop) {
ZStack {
Circle()
.strokeBorder(.primary.opacity(0.2), lineWidth: 1.5)
.frame(width: 44, height: 44)
RoundedRectangle(cornerRadius: 3)
.fill(.primary.opacity(0.7))
.frame(width: 14, height: 14)
}
}
.buttonStyle(.plain)
Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer().frame(height: 8)
}
}
}
// MARK: - Rage Mode Active View
struct DampfAblassenActiveView: View {
@Bindable var workflow: DampfAblassenWorkflow
var body: some View {
VStack(spacing: 0) {
switch workflow.phase {
case .idle, .running:
if workflow.isRecording {
recordingView(onStop: { workflow.stop() })
} else {
VStack(spacing: 12) {
Spacer().frame(height: 24)
ProgressView()
.scaleEffect(0.7)
.controlSize(.small)
if case .running(let msg) = workflow.phase {
Text(msg)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
Spacer().frame(height: 24)
}
}
case .done(let text):
autoPasteView(text: text)
case .error(let msg):
errorView(message: msg) {
workflow.reset()
workflow.start()
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
@ViewBuilder
private func recordingView(onStop: @escaping () -> Void) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
WaveformView(audioLevel: workflow.audioLevel, isRecording: true)
.frame(height: 44)
.padding(.horizontal, 24)
// Monochrome stop button
Button(action: onStop) {
ZStack {
Circle()
.strokeBorder(.primary.opacity(0.2), lineWidth: 1.5)
.frame(width: 44, height: 44)
RoundedRectangle(cornerRadius: 3)
.fill(.primary.opacity(0.7))
.frame(width: 14, height: 14)
}
}
.buttonStyle(.plain)
Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer().frame(height: 8)
}
}
}
// MARK: - Emoji Text Active View
struct EmojiTextActiveView: View {
@Bindable var workflow: EmojiTextWorkflow
var body: some View {
VStack(spacing: 0) {
switch workflow.phase {
case .idle, .running:
if workflow.isRecording {
recordingView(onStop: { workflow.stop() })
} else {
VStack(spacing: 12) {
Spacer().frame(height: 24)
ProgressView()
.scaleEffect(0.7)
.controlSize(.small)
if case .running(let msg) = workflow.phase {
Text(msg)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
Spacer().frame(height: 24)
}
}
case .done(let text):
autoPasteView(text: text)
case .error(let msg):
errorView(message: msg) {
workflow.reset()
workflow.start()
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
@ViewBuilder
private func recordingView(onStop: @escaping () -> Void) -> some View {
VStack(spacing: 16) {
Spacer().frame(height: 20)
WaveformView(audioLevel: workflow.audioLevel, isRecording: true)
.frame(height: 44)
.padding(.horizontal, 24)
// Monochrome stop button
Button(action: onStop) {
ZStack {
Circle()
.strokeBorder(.primary.opacity(0.2), lineWidth: 1.5)
.frame(width: 44, height: 44)
RoundedRectangle(cornerRadius: 3)
.fill(.primary.opacity(0.7))
.frame(width: 14, height: 14)
}
}
.buttonStyle(.plain)
Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer().frame(height: 8)
}
}
}
// MARK: - Shared Result / Error Views
private func processingView(message: String) -> some View {
VStack(spacing: 12) {
Spacer().frame(height: 24)
ProgressView()
.scaleEffect(0.7)
.controlSize(.small)
Text(message)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
Spacer().frame(height: 24)
}
}
private func autoPasteView(text: String) -> some View {
VStack(spacing: 12) {
Spacer().frame(height: 20)
ZStack {
Circle()
.fill(Color.green.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.green)
}
Text("Eingef\u{00FC}gt")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
Text(text)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(3)
.multilineTextAlignment(.center)
.padding(.horizontal, 8)
Spacer().frame(height: 12)
}
}
private func errorView(message: String, onRetry: @escaping () -> Void) -> some View {
VStack(spacing: 10) {
Spacer().frame(height: 16)
ZStack {
Circle()
.fill(Color.orange.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 18))
.foregroundStyle(.orange)
}
Text(message)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
Button(action: onRetry) {
Text("Nochmal versuchen")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.primary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
}
.buttonStyle(SubtleButtonStyle())
Spacer().frame(height: 4)
}
}