import SwiftUI /// T-iOS-31 · 语音 PTT 的 SwiftUI 状态卡。逻辑全在 `VoicePTT.swift` 的 /// `VoicePTTViewModel` 里 —— 本文件只渲染,从 `VoicePTT.swift` 拆出是为了守住 /// 800 行硬上限(plan §4)。 /// PTT 状态卡:听写中 / 待确认 / 撤销窗口 / 被拒 / 失败 / 结果提示。 /// 底部对齐(贴着麦克风键所在的键栏);nil 状态不渲染任何东西。 struct VoicePTTBanner: View { let model: VoicePTTViewModel /// `.idle` 且没有结果提示 ⇒ 整张卡不渲染(终端全屏不被占)。 private var hasCard: Bool { if case .idle = model.phase { return model.noticeText != nil } return true } var body: some View { if hasCard { card .padding(DS.Space.md12) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay( RoundedRectangle(cornerRadius: DS.Radius.md12) .strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline) ) .accessibilityIdentifier("terminal.voice.card") } } @ViewBuilder private var card: some View { switch model.phase { case .requestingPermission: label(VoicePTTViewModel.Copy.requesting, systemImage: "mic.badge.plus") case .listening(let partial): listening(partial: partial) case .confirming(let pending): confirming(pending) case .undoWindow(let pending): undoWindow(pending) case .denied(let reason): notice(reason.message, systemImage: "mic.slash") case .failed(let message): notice(message, systemImage: "exclamationmark.triangle") case .idle: if let text = model.noticeText { notice(text, systemImage: "info.circle") } } } private func label(_ text: String, systemImage: String) -> some View { Label(text, systemImage: systemImage) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) } private func listening(partial: String) -> some View { VStack(alignment: .leading, spacing: DS.Space.xs4) { label(VoicePTTViewModel.Copy.listening, systemImage: "waveform") if !partial.isEmpty { Text(partial) .font(DS.Typography.mono(.callout)) .foregroundStyle(DS.Palette.textSecondary) .accessibilityIdentifier("terminal.voice.partial") } Text(VoicePTTViewModel.Copy.listeningHint) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textTertiary) } } private func confirming(_ pending: VoicePendingAction) -> some View { VStack(alignment: .leading, spacing: DS.Space.sm8) { Text(title(for: pending.intent)) .font(DS.Typography.callout.weight(.semibold)) .foregroundStyle(DS.Palette.textPrimary) Text(pending.preview) .font(DS.Typography.mono(.callout)) .foregroundStyle(DS.Palette.textPrimary) .accessibilityIdentifier("terminal.voice.preview") HStack(spacing: DS.Space.sm8) { Button(VoicePTTViewModel.Copy.cancel) { model.cancel() } .buttonStyle(.bordered) .accessibilityIdentifier("terminal.voice.cancelButton") Button(VoicePTTViewModel.Copy.send) { model.confirm() } .buttonStyle(.borderedProminent) .accessibilityIdentifier("terminal.voice.confirmButton") } .frame(minHeight: DS.Layout.minHitTarget) } } private func undoWindow(_ pending: VoicePendingAction) -> some View { HStack(spacing: DS.Space.sm8) { VStack(alignment: .leading, spacing: DS.Space.xs2) { Text(pending.intent.isDecision ? VoicePTTViewModel.Copy.deciding : VoicePTTViewModel.Copy.sending) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) Text(pending.preview) .font(DS.Typography.mono(.callout)) .foregroundStyle(DS.Palette.textPrimary) } Spacer(minLength: DS.Space.sm8) Button(VoicePTTViewModel.Copy.undo) { model.undo() } .buttonStyle(.borderedProminent) .frame(minHeight: DS.Layout.minHitTarget) .accessibilityIdentifier("terminal.voice.undoButton") } } private func notice(_ text: String, systemImage: String) -> some View { HStack(spacing: DS.Space.sm8) { label(text, systemImage: systemImage) Spacer(minLength: DS.Space.sm8) Button { model.clearNotice() } label: { Image(systemName: "xmark") .frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget) } .buttonStyle(.plain) .accessibilityLabel(VoicePTTViewModel.Copy.dismissNotice) } } private func title(for intent: VoiceIntent) -> String { switch intent { case .text: VoicePTTViewModel.Copy.confirmTitle case .decision(.approve): VoicePTTViewModel.Copy.approveTitle case .decision(.reject): VoicePTTViewModel.Copy.rejectTitle } } } extension VoiceIntent { var isDecision: Bool { if case .decision = self { return true } return false } }