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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user