327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct SearchView: View {
|
|
@State private var viewModel = SearchViewModel()
|
|
|
|
// Navigation destination after fetching the full object
|
|
enum Destination {
|
|
case page(PageDTO)
|
|
case book(BookDTO)
|
|
case shelf(ShelfDTO)
|
|
}
|
|
|
|
@State private var destination: Destination? = nil
|
|
@State private var isLoadingDestination = false
|
|
@State private var navigationActive = false
|
|
@State private var loadError: BookStackError? = nil
|
|
|
|
@FocusState private var searchFocused: Bool
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Persistent search bar
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
TextField(L("search.prompt"), text: $viewModel.query)
|
|
.focused($searchFocused)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.submitLabel(.search)
|
|
.onSubmit { viewModel.onQueryChanged() }
|
|
.onChange(of: viewModel.query) { viewModel.onQueryChanged() }
|
|
if !viewModel.query.isEmpty {
|
|
Button {
|
|
viewModel.query = ""
|
|
viewModel.results = []
|
|
searchFocused = false
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 9)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
|
|
Group {
|
|
if viewModel.query.isEmpty {
|
|
recentSearchesView
|
|
} else if viewModel.isSearching {
|
|
LoadingView(message: L("search.loading"))
|
|
} else if viewModel.results.isEmpty {
|
|
ContentUnavailableView.search(text: viewModel.query)
|
|
} else {
|
|
resultsList
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(L("search.title"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task { await viewModel.loadAvailableTags() }
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
filterMenu
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $navigationActive) {
|
|
switch destination {
|
|
case .page(let page): PageReaderView(page: page)
|
|
case .book(let book): BookDetailView(book: book)
|
|
case .shelf(let shelf): BooksInShelfView(shelf: shelf)
|
|
case nil: EmptyView()
|
|
}
|
|
}
|
|
.overlay {
|
|
if isLoadingDestination {
|
|
ZStack {
|
|
Color.black.opacity(0.15).ignoresSafeArea()
|
|
ProgressView(L("search.opening"))
|
|
.padding(20)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
}
|
|
}
|
|
.alert(L("search.error.title"), isPresented: Binding(
|
|
get: { loadError != nil },
|
|
set: { if !$0 { loadError = nil } }
|
|
)) {
|
|
Button(L("common.ok"), role: .cancel) {}
|
|
} message: {
|
|
Text(loadError?.errorDescription ?? L("common.error"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Results List
|
|
|
|
private var resultsList: some View {
|
|
List {
|
|
if let error = viewModel.error {
|
|
ErrorBanner(error: error) {
|
|
viewModel.onFilterChanged()
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
ForEach(viewModel.results) { result in
|
|
Button {
|
|
Task { await open(result) }
|
|
} label: {
|
|
SearchResultRow(result: result)
|
|
}
|
|
.foregroundStyle(.primary)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.animation(.easeInOut, value: viewModel.results.map(\.id))
|
|
}
|
|
|
|
// MARK: - Open result
|
|
|
|
private func open(_ result: SearchResultDTO) async {
|
|
isLoadingDestination = true
|
|
loadError = nil
|
|
do {
|
|
switch result.type {
|
|
case .page:
|
|
let page = try await BookStackAPI.shared.fetchPage(id: result.id)
|
|
destination = .page(page)
|
|
case .book:
|
|
let book = try await BookStackAPI.shared.fetchBook(id: result.id)
|
|
destination = .book(book)
|
|
case .shelf:
|
|
let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: result.id)
|
|
// Build a ShelfDTO from the ShelfBooksResponse for BooksInShelfView
|
|
let shelf = ShelfDTO(
|
|
id: shelfDetail.id,
|
|
name: shelfDetail.name,
|
|
slug: "",
|
|
description: "",
|
|
createdAt: Date(),
|
|
updatedAt: Date(),
|
|
cover: nil
|
|
)
|
|
destination = .shelf(shelf)
|
|
case .chapter:
|
|
// Navigate to the chapter's parent book
|
|
let chapter = try await BookStackAPI.shared.fetchChapter(id: result.id)
|
|
let book = try await BookStackAPI.shared.fetchBook(id: chapter.bookId)
|
|
destination = .book(book)
|
|
}
|
|
navigationActive = true
|
|
} catch let e as BookStackError {
|
|
loadError = e
|
|
} catch {
|
|
loadError = .unknown(error.localizedDescription)
|
|
}
|
|
isLoadingDestination = false
|
|
}
|
|
|
|
// MARK: - Recent Searches
|
|
|
|
private var recentSearchesView: some View {
|
|
Group {
|
|
if viewModel.recentSearches.isEmpty {
|
|
EmptyStateView(
|
|
systemImage: "magnifyingglass",
|
|
title: L("search.empty.title"),
|
|
message: L("search.empty.message")
|
|
)
|
|
} else {
|
|
List {
|
|
Section {
|
|
ForEach(viewModel.recentSearches, id: \.self) { recent in
|
|
Button {
|
|
viewModel.query = recent
|
|
viewModel.onQueryChanged()
|
|
} label: {
|
|
Label(recent, systemImage: "clock")
|
|
.foregroundStyle(.primary)
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text(L("search.recent"))
|
|
Spacer()
|
|
Button(L("search.recent.clear")) {
|
|
viewModel.clearRecentSearches()
|
|
}
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Filter Menu
|
|
|
|
private var hasActiveFilter: Bool {
|
|
viewModel.selectedTypeFilter != nil || viewModel.selectedTagFilter != nil
|
|
}
|
|
|
|
private var filterMenu: some View {
|
|
Menu {
|
|
// Type filter
|
|
Section(L("search.filter.type")) {
|
|
Button {
|
|
viewModel.selectedTypeFilter = nil
|
|
viewModel.onFilterChanged()
|
|
} label: {
|
|
Label(L("search.filter.all"),
|
|
systemImage: viewModel.selectedTypeFilter == nil ? "checkmark" : "square")
|
|
}
|
|
ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
|
|
Button {
|
|
viewModel.selectedTypeFilter = (viewModel.selectedTypeFilter == type) ? nil : type
|
|
viewModel.onFilterChanged()
|
|
} label: {
|
|
Label(type.displayName,
|
|
systemImage: viewModel.selectedTypeFilter == type ? "checkmark" : type.systemImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tag filter
|
|
if !viewModel.availableTags.isEmpty {
|
|
Section(L("search.filter.tag")) {
|
|
if viewModel.selectedTagFilter != nil {
|
|
Button {
|
|
viewModel.selectedTagFilter = nil
|
|
viewModel.onFilterChanged()
|
|
} label: {
|
|
Label(L("search.filter.tag.clear"), systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
let uniqueNames = Array(Set(viewModel.availableTags.map(\.name))).sorted()
|
|
ForEach(uniqueNames.prefix(15), id: \.self) { name in
|
|
Button {
|
|
viewModel.selectedTagFilter = (viewModel.selectedTagFilter == name) ? nil : name
|
|
viewModel.onFilterChanged()
|
|
} label: {
|
|
Label(name, systemImage: viewModel.selectedTagFilter == name ? "checkmark" : "tag")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: hasActiveFilter
|
|
? "line.3.horizontal.decrease.circle.fill"
|
|
: "line.3.horizontal.decrease.circle")
|
|
}
|
|
.accessibilityLabel(L("search.filter"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Result Row
|
|
|
|
struct SearchResultRow: View {
|
|
let result: SearchResultDTO
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Image(systemName: result.type.systemImage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.blue)
|
|
.frame(width: 18)
|
|
.accessibilityHidden(true)
|
|
|
|
Text(result.name)
|
|
.font(.body.weight(.medium))
|
|
|
|
Spacer()
|
|
|
|
Text(result.type.displayName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
|
}
|
|
|
|
if let preview = result.preview, !preview.isEmpty {
|
|
Text(preview)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.padding(.leading, 26)
|
|
}
|
|
|
|
if !result.tags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 4) {
|
|
ForEach(result.tags) { tag in
|
|
Text(tag.value.isEmpty ? tag.name : "\(tag.name): \(tag.value)")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color(.tertiarySystemBackground), in: Capsule())
|
|
.overlay(Capsule().strokeBorder(Color(.separator), lineWidth: 0.5))
|
|
}
|
|
}
|
|
.padding(.leading, 26)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityLabel("\(result.type.displayName): \(result.name)")
|
|
.accessibilityHint(result.preview ?? "")
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SearchView()
|
|
}
|