diff --git a/CHANGELOG.md b/CHANGELOG.md index c73f6c6..565e496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Notes/Views/Notes/NoteEditorView.swift b/Notes/Views/Notes/NoteEditorView.swift index e3a68ab..807d655 100644 --- a/Notes/Views/Notes/NoteEditorView.swift +++ b/Notes/Views/Notes/NoteEditorView.swift @@ -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 diff --git a/Notes/Views/Notes/NotesListView.swift b/Notes/Views/Notes/NotesListView.swift index ebb8195..ee15156 100644 --- a/Notes/Views/Notes/NotesListView.swift +++ b/Notes/Views/Notes/NotesListView.swift @@ -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 {