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