feat(语音日记): DiaryVoicePanel 录音/整理面板
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
141
康康/Features/Diary/DiaryVoicePanel.swift
Normal file
141
康康/Features/Diary/DiaryVoicePanel.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user