1025 lines
35 KiB
Swift
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)
|
|
}
|
|
}
|