// // SearchTabView.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 23.01.24. // import Foundation import SwiftUI struct SearchTabView: View { @EnvironmentObject var viewModel: SearchTabView.ViewModel @EnvironmentObject var appState: AppState @EnvironmentObject var mealPlan: MealPlanManager var body: some View { NavigationStack { List { let results = viewModel.recipesFiltered() if viewModel.searchText.isEmpty { // Icon + explainer Section { VStack(spacing: 12) { Image(systemName: "magnifyingglass") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("Search for recipes") .font(.headline) .foregroundStyle(.secondary) Text("Enter a recipe name or keyword to get started.") .font(.subheadline) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 24) .listRowSeparator(.hidden) } // Search history if !viewModel.searchHistory.isEmpty { Section { ForEach(viewModel.searchHistory, id: \.self) { term in Button { viewModel.searchText = term } label: { HStack(spacing: 10) { Image(systemName: "clock.arrow.circlepath") .foregroundStyle(.secondary) .font(.subheadline) Text(term) .font(.subheadline) .foregroundStyle(.primary) Spacer() } } } .onDelete { offsets in viewModel.removeHistory(at: offsets) } } header: { HStack { Text("Recent searches") Spacer() Button { viewModel.clearHistory() } label: { Text("Clear") .font(.caption) .foregroundStyle(.secondary) } } } } } else if results.isEmpty { // No results Section { VStack(spacing: 12) { Image(systemName: "magnifyingglass.circle") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("No results found") .font(.headline) .foregroundStyle(.secondary) Text("Try a different search term.") .font(.subheadline) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 24) .listRowSeparator(.hidden) } } else { // Results Section { ForEach(results, id: \.recipe_id) { recipe in SearchRecipeRow(recipe: recipe) .background( NavigationLink(value: recipe) { EmptyView() } .buttonStyle(.plain) .opacity(0) ) .listRowInsets(EdgeInsets(top: 6, leading: 15, bottom: 6, trailing: 15)) .listRowSeparatorTint(.clear) } } } } .listStyle(.plain) .navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results") .navigationDestination(for: Recipe.self) { recipe in RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) .environmentObject(mealPlan) } .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") .onSubmit(of: .search) { viewModel.saveToHistory(viewModel.searchText) } } .task { if viewModel.allRecipes.isEmpty { viewModel.allRecipes = await appState.getRecipes() } } .refreshable { viewModel.allRecipes = await appState.getRecipes() } } class ViewModel: ObservableObject { @Published var allRecipes: [Recipe] = [] @Published var searchText: String = "" @Published var searchMode: SearchMode = .name @Published var searchHistory: [String] = [] private static let historyKey = "searchHistory" private static let maxHistory = 15 init() { self.searchHistory = UserDefaults.standard.stringArray(forKey: Self.historyKey) ?? [] } enum SearchMode: String, CaseIterable { case name = "Name & Keywords", ingredient = "Ingredients" } func recipesFiltered() -> [Recipe] { guard searchText != "" else { return [] } if searchMode == .name { return allRecipes.filter { recipe in recipe.name.lowercased().contains(searchText.lowercased()) || (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) } } else if searchMode == .ingredient { // TODO: Fuzzy ingredient search } return [] } func saveToHistory(_ term: String) { let trimmed = term.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } searchHistory.removeAll { $0.lowercased() == trimmed.lowercased() } searchHistory.insert(trimmed, at: 0) if searchHistory.count > Self.maxHistory { searchHistory = Array(searchHistory.prefix(Self.maxHistory)) } UserDefaults.standard.set(searchHistory, forKey: Self.historyKey) } func removeHistory(at offsets: IndexSet) { searchHistory.remove(atOffsets: offsets) UserDefaults.standard.set(searchHistory, forKey: Self.historyKey) } func clearHistory() { searchHistory = [] UserDefaults.standard.removeObject(forKey: Self.historyKey) } } } // MARK: - Horizontal row card for search results private struct SearchRecipeRow: View { @EnvironmentObject var appState: AppState @State var recipe: Recipe @State private var recipeThumb: UIImage? private var keywordsText: String? { guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil } let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } guard !items.isEmpty else { return nil } return items.prefix(3).joined(separator: " \u{00B7} ") } var body: some View { HStack(spacing: 10) { if let recipeThumb { Image(uiImage: recipeThumb) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 70, height: 70) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { LinearGradient( gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), startPoint: .topLeading, endPoint: .bottomTrailing ) .frame(width: 70, height: 70) .overlay { Image(systemName: "fork.knife") .foregroundStyle(.white.opacity(0.7)) } .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 3) { Text(recipe.name) .font(.subheadline) .fontWeight(.medium) .lineLimit(2) if let keywordsText { Text(keywordsText) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() } .task { recipeThumb = await appState.getImage( id: recipe.recipe_id, size: .THUMB, fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer ) } } }