Move search to a dedicated tab in the tab bar

Replace the Notes list's searchable accessory with a Tab(role: .search)
— the separated magnifier at the trailing edge of the iOS 26 tab bar —
backed by a new SearchView over text, tags, and capture location. The
Notes list drops its search state and always shows the context shelf
plus all notes.

Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
This commit is contained in:
2026-07-15 16:25:34 -04:00
parent 798abf3b90
commit 970eebaf3c
4 changed files with 91 additions and 26 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Transcription now supports many more languages, including Russian, Ukrainian, an
New notes start from two big buttons right above the list — one for text, one for voice
On iPhone, search now lives in the toolbar at the bottom of the screen
Search now has its own tab in the bottom tab bar
Record voice notes that are transcribed to text right on your device, with the original audio kept alongside the note for playback
+6
View File
@@ -19,6 +19,12 @@ struct ContentView: View {
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
// role: .search renders as the separated magnifier tab at the
// trailing edge of the tab bar on iOS 26.
Tab("Search", systemImage: "magnifyingglass", role: .search) {
SearchView()
}
}
}
}
+5 -25
View File
@@ -3,13 +3,13 @@ import SwiftData
import SwiftUI
/// The main list: a "Right here, right now" shelf of context-relevant notes
/// on top, then everything else newest-first. Search filters text and tags.
/// on top, then everything else newest-first. Search lives in its own tab
/// (`SearchView`, the tab bar's trailing magnifier).
struct NotesListView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var composing = false
@State private var recording = false
@State private var editingNote: NoteEntity?
@@ -21,15 +21,6 @@ struct NotesListView: View {
list
}
.navigationTitle("Notes")
// iOS 26: attached at the NavigationStack root inside a TabView, this
// renders as the Liquid-Glass toolbar search accessory (bottom, in the
// tab bar). `.searchToolbarBehavior(.minimize)` collapses it to the
// magnifier capsule until tapped a modifier that only exists on iOS,
// hence the guard; macOS keeps the standard toolbar search field.
.searchable(text: $searchText, prompt: "Search notes and tags")
#if os(iOS)
.searchToolbarBehavior(.minimize)
#endif
.sheet(isPresented: $composing) {
NoteEditorView(mode: .create)
}
@@ -79,7 +70,7 @@ struct NotesListView: View {
}
}
if searchText.isEmpty, !relevantNotes.isEmpty {
if !relevantNotes.isEmpty {
Section("Right here, right now") {
ForEach(relevantNotes) { note in
row(note)
@@ -87,8 +78,8 @@ struct NotesListView: View {
}
}
Section(searchText.isEmpty ? "All notes" : "Results") {
ForEach(filteredNotes) { note in
Section("All notes") {
ForEach(notes) { note in
row(note)
}
}
@@ -133,17 +124,6 @@ struct NotesListView: View {
.map(\.note)
}
private var filteredNotes: [NoteEntity] {
guard !searchText.isEmpty else { return notes }
let needle = searchText.lowercased()
return notes.filter { note in
note.text.lowercased().contains(needle)
|| note.tags.contains { $0.lowercased().contains(needle) }
|| (note.placeName?.lowercased().contains(needle) ?? false)
|| (note.locality?.lowercased().contains(needle) ?? false)
}
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
+79
View File
@@ -0,0 +1,79 @@
import IndieSync
import SwiftData
import SwiftUI
/// The Search tab: full-library search over note text, tags, and capture
/// location. Lives behind the tab bar's dedicated search tab (`role: .search`),
/// which iOS 26 renders as the separated magnifier at the trailing edge.
struct SearchView: View {
@Environment(SyncEngine.self) private var syncEngine
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var editingNote: NoteEntity?
var body: some View {
NavigationStack {
List {
ForEach(results) { note in
row(note)
}
}
.navigationTitle("Search")
.searchable(text: $searchText, prompt: "Search notes and tags")
.overlay {
if searchText.isEmpty {
ContentUnavailableView(
"Search your notes",
systemImage: "magnifyingglass",
description: Text("Find notes by text, tag, or the place you captured them.")
)
} else if results.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}
private func row(_ note: NoteEntity) -> some View {
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(note)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private var results: [NoteEntity] {
guard !searchText.isEmpty else { return [] }
return notes.filter { $0.matches(search: searchText) }
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
try? await syncEngine.deleteNote(id: id)
}
}
}
extension NoteEntity {
/// Case-insensitive match against text, tags, and capture location.
func matches(search: String) -> Bool {
let needle = search.lowercased()
return text.lowercased().contains(needle)
|| tags.contains { $0.lowercased().contains(needle) }
|| (placeName?.lowercased().contains(needle) ?? false)
|| (locality?.lowercased().contains(needle) ?? false)
}
}