Refactor capture UX: single New Note toolbar button, text-first editor

- NotesListView: remove two-button header; trailing New Note toolbar item (Cmd-N)
- NoteEditorView: text entry focused by default; extensible add-media bar
  (CaptureMedium enum, audio only for now) revealing the inline recorder;
  startRecording param removed
- Changelog entry updated
This commit is contained in:
2026-07-15 18:39:11 -04:00
parent 970eebaf3c
commit 35e4d6750b
3 changed files with 90 additions and 59 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Transcription now supports many more languages, including Russian, Ukrainian, and Polish Transcription now supports many more languages, including Russian, Ukrainian, and Polish
New notes start from two big buttons right above the list — one for text, one for voice A single toolbar button now starts a new note that opens straight to typing, with voice recording just one tap away
Search now has its own tab in the bottom tab bar Search now has its own tab in the bottom tab bar
+71 -16
View File
@@ -5,18 +5,37 @@ import SwiftUI
/// snapshots the current context (refreshing location in the background); /// snapshots the current context (refreshing location in the background);
/// on edit the original capture context is preserved untouched. /// on edit the original capture context is preserved untouched.
/// ///
/// A note can carry audio: create mode can record one (optionally auto-starting /// Create mode is text-first: the text editor is focused immediately, with
/// from the toolbar mic), and edit mode plays it back, shows transcription /// an "add media" bar below it for attaching other content. Today that bar
/// status, and offers a per-note "Re-transcribe" override. /// has one medium voice which reveals the recording flow inline. Edit
/// mode plays back any attached audio, shows transcription status, and
/// offers a per-note "Re-transcribe" override.
struct NoteEditorView: View { struct NoteEditorView: View {
enum Mode { enum Mode {
case create case create
case edit(NoteEntity) case edit(NoteEntity)
} }
/// A kind of content that can be attached to a note from the "add media"
/// bar in create mode. Only `.audio` exists today; adding a future medium
/// (photo, file, link) means adding a case here plus its icon/label.
private enum CaptureMedium: CaseIterable, Hashable {
case audio
var icon: String {
switch self {
case .audio: "mic"
}
}
var label: String {
switch self {
case .audio: "Add Voice"
}
}
}
let mode: Mode let mode: Mode
/// Begin recording the moment the composer appears (toolbar mic entry point).
var startRecording = false
@Environment(SyncEngine.self) private var syncEngine @Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService @Environment(ContextService.self) private var contextService
@@ -28,21 +47,24 @@ struct NoteEditorView: View {
@State private var tagsText = "" @State private var tagsText = ""
@State private var saveError: String? @State private var saveError: String?
@State private var recordedAudio: AudioRecorderService.Recording? @State private var recordedAudio: AudioRecorderService.Recording?
/// The medium currently expanded in the "add media" bar (revealing its
/// capture flow), or `nil` when the bar is showing only its buttons.
@State private var expandedMedium: CaptureMedium?
@FocusState private var textFocused: Bool @FocusState private var textFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
if isCreating {
audioComposeSection
}
Section { Section {
TextEditor(text: $text) TextEditor(text: $text)
.focused($textFocused) .focused($textFocused)
.frame(minHeight: 160) .frame(minHeight: 160)
} }
if isCreating {
mediaSection
}
if case .edit(let note) = mode, note.hasAudio { if case .edit(let note) = mode, note.hasAudio {
audioPlaybackSection(for: note) audioPlaybackSection(for: note)
} }
@@ -89,8 +111,7 @@ struct NoteEditorView: View {
.task { .task {
switch mode { switch mode {
case .create: case .create:
// Don't steal focus from the mic entry point. textFocused = true
if !startRecording { textFocused = true }
await contextService.refresh() await contextService.refresh()
case .edit(let note): case .edit(let note):
text = note.text text = note.text
@@ -102,11 +123,19 @@ struct NoteEditorView: View {
#endif #endif
} }
// MARK: - Audio (create) // MARK: - Media (create)
/// The "add media" bar plus whichever capture flow is currently expanded
/// beneath it. Buttons drop out of the bar once their medium is expanded
/// or attached, so there's never a stale "Add Voice" button sitting above
/// an in-progress recording or a finished one.
@ViewBuilder @ViewBuilder
private var audioComposeSection: some View { private var mediaSection: some View {
Section("Voice") { Section {
if !availableMedia.isEmpty {
mediaBar
}
if let recordedAudio { if let recordedAudio {
AudioPlayerView(source: .localURL(recordedAudio.url)) AudioPlayerView(source: .localURL(recordedAudio.url))
Button(role: .destructive) { Button(role: .destructive) {
@@ -115,15 +144,41 @@ struct NoteEditorView: View {
} label: { } label: {
Label("Discard Recording", systemImage: "trash") Label("Discard Recording", systemImage: "trash")
} }
} else { } else if expandedMedium == .audio {
AudioRecordingControl(autostart: startRecording) { url, duration in AudioRecordingControl { url, duration in
recordedAudio = AudioRecorderService.Recording(url: url, duration: duration) recordedAudio = AudioRecorderService.Recording(url: url, duration: duration)
expandedMedium = nil
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
} }
} }
private var mediaBar: some View {
HStack(spacing: 12) {
ForEach(availableMedia, id: \.self) { medium in
Button {
expandedMedium = medium
} label: {
Label(medium.label, systemImage: medium.icon)
}
.buttonStyle(.bordered)
}
Spacer()
}
}
/// Media not yet attached and not already expanded below the bar.
private var availableMedia: [CaptureMedium] {
CaptureMedium.allCases.filter { $0 != expandedMedium && !isAttached($0) }
}
private func isAttached(_ medium: CaptureMedium) -> Bool {
switch medium {
case .audio: recordedAudio != nil
}
}
// MARK: - Audio (edit) // MARK: - Audio (edit)
@ViewBuilder @ViewBuilder
+18 -42
View File
@@ -11,55 +11,31 @@ struct NotesListView: View {
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var composing = false @State private var composing = false
@State private var recording = false
@State private var editingNote: NoteEntity? @State private var editingNote: NoteEntity?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { list
captureButtons .navigationTitle("Notes")
list .toolbar {
} ToolbarItem(placement: .primaryAction) {
.navigationTitle("Notes") Button {
.sheet(isPresented: $composing) { composing = true
NoteEditorView(mode: .create) } label: {
} Label("New Note", systemImage: "square.and.pencil")
.sheet(isPresented: $recording) { }
NoteEditorView(mode: .create, startRecording: true) .keyboardShortcut("n", modifiers: .command)
} }
.sheet(item: $editingNote) { note in }
NoteEditorView(mode: .edit(note)) .sheet(isPresented: $composing) {
} NoteEditorView(mode: .create)
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
} }
} }
/// The two prominent capture actions, side by side above the list the
/// primary way to start a note, promoted out of the toolbar. Glass-prominent
/// capsules inherit the app's red accent; each fills half the width.
private var captureButtons: some View {
HStack(spacing: 12) {
Button {
composing = true
} label: {
Label("Text", systemImage: "square.and.pencil")
.frame(maxWidth: .infinity)
}
.keyboardShortcut("n", modifiers: .command)
Button {
recording = true
} label: {
Label("Audio", systemImage: "mic.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.glassProminent)
.controlSize(.large)
.padding(.horizontal)
.padding(.top, 4)
.padding(.bottom, 8)
}
private var list: some View { private var list: some View {
List { List {
if let error = syncEngine.lastSyncError { if let error = syncEngine.lastSyncError {