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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
captureButtons
|
|
||||||
list
|
list
|
||||||
}
|
|
||||||
.navigationTitle("Notes")
|
.navigationTitle("Notes")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
composing = true
|
||||||
|
} label: {
|
||||||
|
Label("New Note", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $composing) {
|
.sheet(isPresented: $composing) {
|
||||||
NoteEditorView(mode: .create)
|
NoteEditorView(mode: .create)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $recording) {
|
|
||||||
NoteEditorView(mode: .create, startRecording: true)
|
|
||||||
}
|
|
||||||
.sheet(item: $editingNote) { note in
|
.sheet(item: $editingNote) { note in
|
||||||
NoteEditorView(mode: .edit(note))
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user