import SwiftUI // MARK: - SearchView // Full-screen search tab. // Idle: recent searches list (or prompt if empty). // Active: debounced live results in a 2-col grid with local/remote count header. struct SearchView: View { @State private var vm = SearchViewModel() @EnvironmentObject private var networkMonitor: NetworkMonitor private let columns = [ GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14), ] var body: some View { NavigationStack { VStack(spacing: 0) { OfflineBanner() Group { if vm.isLoading { loadingState } else if !vm.query.isEmpty && vm.results.isEmpty { emptyResultsState } else if !vm.results.isEmpty { resultsGrid } else { idleState } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .appNavigationDestination() .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationTitle("Search") .navigationBarTitleDisplayMode(.large) .searchable( text: $vm.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search novels, authors…" ) .onChange(of: vm.query) { _, newValue in guard networkMonitor.isConnected else { return } vm.onQueryChange(newValue) } .onSubmit(of: .search) { guard networkMonitor.isConnected else { return } UIImpactFeedbackGenerator(style: .light).impactOccurred() vm.submitSearch() } .errorAlert($vm.error) } } // MARK: - Idle state @ViewBuilder private var idleState: some View { if vm.recentSearches.isEmpty { emptyIdleState } else { recentSearchesList } } private var emptyIdleState: some View { VStack(spacing: 16) { Spacer() Image(systemName: "magnifyingglass") .font(.system(size: 60)) .foregroundStyle(.tertiary) Text("Search for novels") .font(.title3.bold()) .foregroundStyle(.primary) Text("Find books by title, author, or genre") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 40) Spacer() } } private var recentSearchesList: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { HStack { Text("Recent Searches") .font(.subheadline.bold()) .foregroundStyle(.secondary) Spacer() Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() vm.clearRecent() } label: { Text("Clear") .font(.subheadline) .foregroundStyle(Color.amber) } .frame(minWidth: 44, minHeight: 44) } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) ForEach(vm.recentSearches, id: \.self) { term in Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() vm.selectRecent(term) } label: { HStack(spacing: 12) { Image(systemName: "clock") .font(.subheadline) .foregroundStyle(.tertiary) .frame(width: 24) Text(term) .font(.body) .foregroundStyle(.primary) .lineLimit(1) Spacer() Image(systemName: "arrow.up.left") .font(.caption) .foregroundStyle(.tertiary) } .padding(.horizontal, 16) .frame(minHeight: 44) } .buttonStyle(.plain) .contentShape(Rectangle()) Divider() .padding(.leading, 52) } } Color.clear.frame(height: 120) } } // MARK: - Loading state private var loadingState: some View { VStack { Spacer() ProgressView() .tint(Color.amber) .scaleEffect(1.4) Spacer() } } // MARK: - Empty results state private var emptyResultsState: some View { VStack { Spacer() EmptyStateView( icon: "magnifyingglass", title: "No results", message: "Nothing matched \"\(vm.query)\". Try a different term." ) Spacer() } } // MARK: - Results grid private var resultsGrid: some View { ScrollView { // Count header HStack(spacing: 6) { Text("\(vm.results.count) results") .font(.subheadline.bold()) .foregroundStyle(.primary) if vm.localCount > 0 || vm.remoteCount > 0 { Text("·") .foregroundStyle(.tertiary) if vm.localCount > 0 { Text("\(vm.localCount) in library") .font(.caption) .foregroundStyle(Color.amber) } if vm.localCount > 0 && vm.remoteCount > 0 { Text("+") .font(.caption) .foregroundStyle(.tertiary) } if vm.remoteCount > 0 { Text("\(vm.remoteCount) online") .font(.caption) .foregroundStyle(.secondary) } } Spacer() } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) LazyVGrid(columns: columns, spacing: 14) { ForEach(vm.results) { novel in NavigationLink(value: NavDestination.book(novel.slug)) { SearchNovelCard(novel: novel) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.top, 4) Color.clear.frame(height: 120) } } } // MARK: - SearchNovelCard private struct SearchNovelCard: View { let novel: BrowseNovel var body: some View { VStack(alignment: .leading, spacing: 0) { AsyncCoverImage(url: novel.cover) .frame(maxWidth: .infinity) .aspectRatio(2/3, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .bookCoverZoomSource(slug: novel.slug) VStack(alignment: .leading, spacing: 3) { Text(novel.title) .font(.subheadline.bold()) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) if !novel.author.isEmpty { Text(novel.author) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.horizontal, 10) .padding(.vertical, 10) } .frame(maxWidth: .infinity, alignment: .leading) .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) } }