SwiftUI + SwiftData + iCloud, Apple Speech transcription (German), audio recording, summarization service protocol (LLM-ready), localization scaffolding (EN/DE/ES/FR), basic tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
3.8 KiB
Swift
157 lines
3.8 KiB
Swift
import SwiftUI
|
|
|
|
struct DiaryEntryView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(DiaryViewModel.self) private var viewModel
|
|
let entry: DiaryEntry
|
|
@State private var selectedTab = 0
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Picker(String(localized: "entry.viewMode"), selection: $selectedTab) {
|
|
Text(String(localized: "entry.tab.summary")).tag(0)
|
|
Text(String(localized: "entry.tab.transcripts")).tag(1)
|
|
Text(String(localized: "entry.tab.memos")).tag(2)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding()
|
|
|
|
TabView(selection: $selectedTab) {
|
|
summaryTab
|
|
.tag(0)
|
|
transcriptsTab
|
|
.tag(1)
|
|
memosTab
|
|
.tag(2)
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
}
|
|
.navigationTitle(entry.formattedDate)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
// MARK: - Summary Tab
|
|
|
|
private var summaryTab: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if let summary = entry.summary {
|
|
Text(LocalizedStringKey(summary))
|
|
.font(.body)
|
|
.textSelection(.enabled)
|
|
} else if viewModel.isSummarizing {
|
|
ProgressView(String(localized: "summary.generating"))
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.top, 40)
|
|
} else if entry.hasTranscripts {
|
|
ContentUnavailableView {
|
|
Label(
|
|
String(localized: "summary.empty.title"),
|
|
systemImage: "text.document"
|
|
)
|
|
} description: {
|
|
Text(String(localized: "summary.empty.description"))
|
|
} actions: {
|
|
Button {
|
|
Task { await viewModel.generateSummary(for: entry) }
|
|
} label: {
|
|
Text(String(localized: "summary.generate"))
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
} else {
|
|
ContentUnavailableView(
|
|
String(localized: "summary.noTranscripts.title"),
|
|
systemImage: "waveform.slash",
|
|
description: Text(String(localized: "summary.noTranscripts.description"))
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// MARK: - Transcripts Tab
|
|
|
|
private var transcriptsTab: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if entry.combinedTranscript.isEmpty {
|
|
ContentUnavailableView(
|
|
String(localized: "transcripts.empty.title"),
|
|
systemImage: "text.bubble",
|
|
description: Text(String(localized: "transcripts.empty.description"))
|
|
)
|
|
} else {
|
|
Text(entry.combinedTranscript)
|
|
.font(.body)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// MARK: - Memos Tab
|
|
|
|
private var memosTab: some View {
|
|
List {
|
|
ForEach(entry.memos.sorted(by: { $0.recordedAt < $1.recordedAt })) { memo in
|
|
MemoRow(memo: memo)
|
|
}
|
|
.onDelete { indexSet in
|
|
let sorted = entry.memos.sorted { $0.recordedAt < $1.recordedAt }
|
|
for index in indexSet {
|
|
viewModel.deleteMemo(sorted[index], from: entry, context: modelContext)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.overlay {
|
|
if entry.memos.isEmpty {
|
|
ContentUnavailableView(
|
|
String(localized: "memos.empty.title"),
|
|
systemImage: "mic.slash",
|
|
description: Text(String(localized: "memos.empty.description"))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MemoRow: View {
|
|
let memo: VoiceMemo
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Label(memo.formattedDuration, systemImage: "waveform")
|
|
.font(.subheadline.weight(.medium))
|
|
|
|
Spacer()
|
|
|
|
Text(memo.recordedAt, style: .time)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if memo.isTranscribing {
|
|
HStack(spacing: 6) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "memo.transcribing"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if let transcript = memo.transcript {
|
|
Text(transcript.prefix(100))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|