Files
linkding-ios/Marks/Views/SearchView.swift
Krishna Kumar b7ef5c2215 Replace Flutter app with native SwiftUI rewrite (iOS 26)
Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart
codebase entirely in favour of a lean native app with no third-party dependencies.

Features shipped:
- Bookmark list with pagination, pull-to-refresh, swipe delete/archive
- Add bookmark form + iOS share extension (zero-tap save from any app)
- Tags tab — all tags sorted by count, tap to browse filtered bookmarks
- Native search tab (Tab role: .search) with instant client-side filtering
- AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary
  and tag generation, semantic search, smart collections
- Settings: linkding server config, OpenRouter API key
- App Groups for credential sharing between main app and share extension
- Swift 6 strict concurrency throughout (@Observable, @MainActor)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:33:01 -05:00

88 lines
3.5 KiB
Swift

import SwiftUI
struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = ""
@State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>?
var body: some View {
NavigationStack {
List {
ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Search")
.toolbar {
if viewModel.claude != nil {
ToolbarItem(placement: .topBarTrailing) {
Toggle(isOn: $useSemanticSearch) {
Label("Semantic", systemImage: "sparkles")
}
.toggleStyle(.button)
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
}
}
}
.overlay {
if searchText.isEmpty {
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
} else if results.isEmpty && !viewModel.isLoading {
ContentUnavailableView.search(text: searchText)
}
if viewModel.isLoading {
ProgressView()
}
}
}
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() }
}
private var results: [Bookmark] {
if let semantic = semanticResults { return semantic }
guard !searchText.isEmpty else { return [] }
let q = searchText.lowercased()
return viewModel.bookmarks.filter { b in
b.displayTitle.lowercased().contains(q) ||
b.url.lowercased().contains(q) ||
b.tagNames.contains { $0.lowercased().contains(q) } ||
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
(b.aiSummary ?? "").lowercased().contains(q)
}
}
private func scheduleSearch() {
semanticResults = nil
searchTask?.cancel()
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
let q = searchText
let all = viewModel.bookmarks
do {
let ranked = try await claude.semanticSearch(query: q, in: all)
await MainActor.run { semanticResults = ranked }
} catch {}
}
}
}