Files
bookstax/bookstax/Views/Search/SearchView.swift
T

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()
}