142 lines
5.1 KiB
Swift
142 lines
5.1 KiB
Swift
import SwiftUI
|
|
|
|
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
|
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
|
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
|
struct DiaryVoicePanel: View {
|
|
enum Mode: Equatable {
|
|
case recording(elapsedSeconds: Int)
|
|
case organizing
|
|
}
|
|
|
|
let mode: Mode
|
|
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
|
let transcript: String
|
|
let onStop: () -> Void
|
|
let onCancelOrganize: () -> Void
|
|
|
|
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
|
static let maxRecordingSeconds = 180
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
header
|
|
transcriptArea
|
|
if case .recording = mode {
|
|
stopButton
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
|
)
|
|
.overlay(alignment: .bottom) {
|
|
if mode == .organizing {
|
|
AIFlowBar().padding(.horizontal, 1)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var header: some View {
|
|
switch mode {
|
|
case .recording(let elapsed):
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "waveform")
|
|
.font(.tjScaled(12, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.brick)
|
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
|
Text("正在听 · 识别在本机完成")
|
|
.font(.tjScaled(13, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
Spacer(minLength: 0)
|
|
Text(Self.format(elapsed))
|
|
.font(.tjScaled(12, design: .monospaced))
|
|
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
|
? Tj.Palette.brick : Tj.Palette.text3)
|
|
}
|
|
case .organizing:
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "sparkles")
|
|
.font(.tjScaled(12, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.brick)
|
|
.symbolEffect(.pulse, options: .repeating)
|
|
Text("AI 整理中 · 本地推理")
|
|
.font(.tjScaled(13, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
Spacer(minLength: 0)
|
|
Button("取消") { onCancelOrganize() }
|
|
.font(.tjScaled(12, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var transcriptArea: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(showsIndicators: false) {
|
|
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
|
.font(.tjScaled(14))
|
|
.foregroundStyle(transcriptColor)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Color.clear.frame(height: 1).id("tail")
|
|
}
|
|
.frame(maxHeight: 120)
|
|
.onChange(of: transcript) { _, _ in
|
|
proxy.scrollTo("tail", anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var transcriptColor: Color {
|
|
if transcript.isEmpty { return Tj.Palette.text3 }
|
|
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
|
}
|
|
|
|
private var stopButton: some View {
|
|
Button(action: onStop) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "stop.circle.fill")
|
|
Text("说完了,整理成日记")
|
|
}
|
|
.font(.tjScaled(14, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.paper)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.brick)
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private static func format(_ seconds: Int) -> String {
|
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
|
}
|
|
}
|
|
|
|
#Preview("录音中") {
|
|
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
|
onStop: {}, onCancelOrganize: {})
|
|
.padding()
|
|
}
|
|
|
|
#Preview("整理中") {
|
|
DiaryVoicePanel(mode: .organizing,
|
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
|
onStop: {}, onCancelOrganize: {})
|
|
.padding()
|
|
}
|