diff --git a/CHANGELOG.md b/CHANGELOG.md index 788295b..c73f6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Notes/Views/App/ContentView.swift b/Notes/Views/App/ContentView.swift index fd631fa..0722db0 100644 --- a/Notes/Views/App/ContentView.swift +++ b/Notes/Views/App/ContentView.swift @@ -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() + } } } } diff --git a/Notes/Views/Notes/NotesListView.swift b/Notes/Views/Notes/NotesListView.swift index 8bba26c..ebb8195 100644 --- a/Notes/Views/Notes/NotesListView.swift +++ b/Notes/Views/Notes/NotesListView.swift @@ -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 { diff --git a/Notes/Views/Notes/SearchView.swift b/Notes/Views/Notes/SearchView.swift new file mode 100644 index 0000000..d23db11 --- /dev/null +++ b/Notes/Views/Notes/SearchView.swift @@ -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) + } +}