Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Hendrik Hogertz c8ddb098d1 Redesign search tab, add category cards, recent recipes, and complete German translations
Overhaul SearchTabView with search history, empty/no-results states, and dynamic
navigation title. Extract CategoryCardView and RecentRecipesSection into standalone
views. Update RecipeTabView, RecipeListView, RecipeCardView, and MainView for the
modernized UI. Add all 12 missing German translations in Localizable.xcstrings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 01:47:16 +01:00

247 lines
9.6 KiB
Swift

//
// 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
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))
}
.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
)
}
}
}