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
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
+71 -16
View File
@@ -5,18 +5,37 @@ import SwiftUI
/// 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.
/// Create mode is text-first: the text editor is focused immediately, with
/// an "add media" bar below it for attaching other content. Today that bar
/// 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 {
enum Mode {
case create
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
/// 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
@@ -28,21 +47,24 @@ struct NoteEditorView: View {
@State private var tagsText = ""
@State private var saveError: String?
@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
var body: some View {
NavigationStack {
Form {
if isCreating {
audioComposeSection
}
Section {
TextEditor(text: $text)
.focused($textFocused)
.frame(minHeight: 160)
}
if isCreating {
mediaSection
}
if case .edit(let note) = mode, note.hasAudio {
audioPlaybackSection(for: note)
}
@@ -89,8 +111,7 @@ struct NoteEditorView: View {
.task {
switch mode {
case .create:
// Don't steal focus from the mic entry point.
if !startRecording { textFocused = true }
textFocused = true
await contextService.refresh()
case .edit(let note):
text = note.text
@@ -102,11 +123,19 @@ struct NoteEditorView: View {
#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
private var audioComposeSection: some View {
Section("Voice") {
private var mediaSection: some View {
Section {
if !availableMedia.isEmpty {
mediaBar
}
if let recordedAudio {
AudioPlayerView(source: .localURL(recordedAudio.url))
Button(role: .destructive) {
@@ -115,15 +144,41 @@ struct NoteEditorView: View {
} label: {
Label("Discard Recording", systemImage: "trash")
}
} else {
AudioRecordingControl(autostart: startRecording) { url, duration in
} else if expandedMedium == .audio {
AudioRecordingControl { url, duration in
recordedAudio = AudioRecorderService.Recording(url: url, duration: duration)
expandedMedium = nil
}
.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)
@ViewBuilder
+18 -42
View File
@@ -11,55 +11,31 @@ struct NotesListView: View {
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var composing = false
@State private var recording = false
@State private var editingNote: NoteEntity?
var body: some View {
NavigationStack {
VStack(spacing: 0) {
captureButtons
list
}
.navigationTitle("Notes")
.sheet(isPresented: $composing) {
NoteEditorView(mode: .create)
}
.sheet(isPresented: $recording) {
NoteEditorView(mode: .create, startRecording: true)
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
list
.navigationTitle("Notes")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
composing = true
} label: {
Label("New Note", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
}
}
.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 {
List {
if let error = syncEngine.lastSyncError {