- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud - AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline with enabled-language set and confidence-based auto-pick, re-transcribe menu - Settings > Transcription > Languages with asset download/reserve handling - watchOS app + accessory complication: one-button recorder, WCSession transferFile to phone, WatchInbox staging, direct-upsert ingestion - Player UI, transcription status chips, quick-capture mic in notes list - Version 0.2, changelog + README updated
298 lines
11 KiB
Swift
298 lines
11 KiB
Swift
import IndieSync
|
|
import SwiftUI
|
|
|
|
/// Composer for a new note and editor for an existing one. On create it
|
|
/// snapshots the current context (refreshing location in the background);
|
|
/// on edit the original capture context is preserved untouched.
|
|
///
|
|
/// A note can carry audio: create mode can record one (optionally auto-starting
|
|
/// from the toolbar mic), and edit mode plays it back, shows transcription
|
|
/// status, and offers a per-note "Re-transcribe" override.
|
|
struct NoteEditorView: View {
|
|
enum Mode {
|
|
case create
|
|
case edit(NoteEntity)
|
|
}
|
|
|
|
let mode: Mode
|
|
/// Begin recording the moment the composer appears (toolbar mic entry point).
|
|
var startRecording = false
|
|
|
|
@Environment(SyncEngine.self) private var syncEngine
|
|
@Environment(ContextService.self) private var contextService
|
|
@Environment(TranscriptionService.self) private var transcriptionService
|
|
@Environment(TranscriptionSettings.self) private var transcriptionSettings
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var text = ""
|
|
@State private var tagsText = ""
|
|
@State private var saveError: String?
|
|
@State private var recordedAudio: AudioRecorderService.Recording?
|
|
@FocusState private var textFocused: Bool
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if isCreating {
|
|
audioComposeSection
|
|
}
|
|
|
|
Section {
|
|
TextEditor(text: $text)
|
|
.focused($textFocused)
|
|
.frame(minHeight: 160)
|
|
}
|
|
|
|
if case .edit(let note) = mode, note.hasAudio {
|
|
audioPlaybackSection(for: note)
|
|
}
|
|
|
|
Section("Tags") {
|
|
TextField("coffee, people, ideas…", text: $tagsText)
|
|
.autocorrectionDisabled()
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
}
|
|
|
|
Section("Context") {
|
|
if isCreating {
|
|
ContextSummaryRow(
|
|
context: contextService.current,
|
|
isRefreshing: contextService.isRefreshing
|
|
)
|
|
} else if case .edit(let note) = mode {
|
|
ContextSummaryRow(context: note.context, isRefreshing: false)
|
|
}
|
|
}
|
|
|
|
if let saveError {
|
|
Section {
|
|
Label(saveError, systemImage: "exclamationmark.icloud")
|
|
.foregroundStyle(.red)
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.navigationTitle(isCreating ? "New Note" : "Edit Note")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") { save() }
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
switch mode {
|
|
case .create:
|
|
// Don't steal focus from the mic entry point.
|
|
if !startRecording { textFocused = true }
|
|
await contextService.refresh()
|
|
case .edit(let note):
|
|
text = note.text
|
|
tagsText = note.tags.joined(separator: ", ")
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 480, minHeight: 420)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Audio (create)
|
|
|
|
@ViewBuilder
|
|
private var audioComposeSection: some View {
|
|
Section("Voice") {
|
|
if let recordedAudio {
|
|
AudioPlayerView(source: .localURL(recordedAudio.url))
|
|
Button(role: .destructive) {
|
|
try? FileManager.default.removeItem(at: recordedAudio.url)
|
|
self.recordedAudio = nil
|
|
} label: {
|
|
Label("Discard Recording", systemImage: "trash")
|
|
}
|
|
} else {
|
|
AudioRecordingControl(autostart: startRecording) { url, duration in
|
|
recordedAudio = AudioRecorderService.Recording(url: url, duration: duration)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Audio (edit)
|
|
|
|
@ViewBuilder
|
|
private func audioPlaybackSection(for note: NoteEntity) -> some View {
|
|
Section("Voice") {
|
|
if let id = ULID(string: note.id) {
|
|
AudioPlayerView(source: .note(id: id, duration: note.audioDuration ?? 0))
|
|
}
|
|
transcriptionStatusRow(for: note)
|
|
retranscribeMenu(for: note)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func transcriptionStatusRow(for note: NoteEntity) -> some View {
|
|
switch note.audio?.transcriptionStatus {
|
|
case .pending:
|
|
if note.transcriberDeviceID == TranscriptionSettings.installID {
|
|
Label {
|
|
Text("Transcribing…")
|
|
} icon: {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Label("Waiting to transcribe on another device", systemImage: "hourglass")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .failed:
|
|
HStack {
|
|
Label("Transcription failed", systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Retry") { retranscribe(note: note, localeID: nil) }
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
case .done:
|
|
if let localeID = note.audio?.transcriptionLocaleID {
|
|
Label(
|
|
"Transcribed · \(TranscriptionSettings.displayName(for: Locale(identifier: localeID)))",
|
|
systemImage: "checkmark.circle"
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
.font(.footnote)
|
|
}
|
|
case .none:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func retranscribeMenu(for note: NoteEntity) -> some View {
|
|
let locales = transcriptionSettings.enabledLocales
|
|
if !locales.isEmpty {
|
|
Menu {
|
|
Button("Auto-detect Language") { retranscribe(note: note, localeID: nil) }
|
|
Divider()
|
|
ForEach(locales, id: \.identifier) { locale in
|
|
Button(TranscriptionSettings.displayName(for: locale)) {
|
|
retranscribe(note: note, localeID: TranscriptionSettings.canonicalID(locale))
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Re-transcribe", systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func retranscribe(note: NoteEntity, localeID: String?) {
|
|
guard let id = ULID(string: note.id) else { return }
|
|
Task {
|
|
try? await syncEngine.requestRetranscription(id: id, localeID: localeID)
|
|
transcriptionService.enqueue(noteID: id, forcedLocaleID: localeID)
|
|
}
|
|
}
|
|
|
|
// MARK: - State
|
|
|
|
private var isCreating: Bool {
|
|
if case .create = mode { return true }
|
|
return false
|
|
}
|
|
|
|
private var canSave: Bool {
|
|
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
|
|
if recordedAudio != nil { return true }
|
|
if case .edit(let note) = mode, note.hasAudio { return true }
|
|
return false
|
|
}
|
|
|
|
private var parsedTags: [String] {
|
|
tagsText
|
|
.split(separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func save() {
|
|
guard canSave else { return }
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
Task {
|
|
do {
|
|
switch mode {
|
|
case .create:
|
|
if let recordedAudio {
|
|
let note = try await syncEngine.createAudioNote(
|
|
audioFileURL: recordedAudio.url,
|
|
duration: recordedAudio.duration,
|
|
text: trimmed,
|
|
tags: parsedTags,
|
|
context: contextService.current,
|
|
transcriberDeviceID: TranscriptionSettings.installID
|
|
)
|
|
// Fast path: hand the recording's temp file straight to the
|
|
// transcriber so it doesn't round-trip through the container.
|
|
transcriptionService.enqueue(noteID: note.id, localAudioURL: recordedAudio.url)
|
|
} else {
|
|
try await syncEngine.createNote(
|
|
text: trimmed,
|
|
tags: parsedTags,
|
|
context: contextService.current
|
|
)
|
|
}
|
|
case .edit(let entity):
|
|
guard let id = ULID(string: entity.id),
|
|
var note = await syncEngine.loadNote(id: id) else { return }
|
|
note.payload.text = trimmed
|
|
note.payload.tags = parsedTags
|
|
try await syncEngine.updateNote(note)
|
|
}
|
|
dismiss()
|
|
} catch {
|
|
saveError = "Couldn't save: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One-line "where and when" readout shown in the composer, so the user sees
|
|
/// what context is being stamped onto the note.
|
|
struct ContextSummaryRow: View {
|
|
let context: CaptureContext
|
|
let isRefreshing: Bool
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Image(systemName: context.hasCoordinate ? "location.fill" : "location.slash")
|
|
.foregroundStyle(context.hasCoordinate ? Color.accentColor : .secondary)
|
|
if let summary = context.summary {
|
|
Text(summary)
|
|
} else if isRefreshing {
|
|
Text("Finding where you are…")
|
|
.foregroundStyle(.secondary)
|
|
} else if context.hasCoordinate {
|
|
Text("Location captured")
|
|
} else {
|
|
Text("No location")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if isRefreshing {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.font(.callout)
|
|
}
|
|
}
|