Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Hendrik Hogertz 8b23652f10 Add meal plan feature with cross-device sync and automatic stale data cleanup
Introduces weekly meal planning with a calendar-based tab view, per-recipe
date assignments synced via Nextcloud Cookbook custom metadata, and 30-day
automatic pruning of old entries on load, save, and sync merge.

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

249 lines
9.7 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
@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
)
}
}
}