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>
This commit is contained in:
@@ -14,44 +14,66 @@ struct RecipeTabView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $viewModel.selectedCategory) {
|
||||
// Categories
|
||||
ForEach(appState.categories) { category in
|
||||
NavigationLink(value: category) {
|
||||
HStack(alignment: .center) {
|
||||
if viewModel.selectedCategory != nil &&
|
||||
category.name == viewModel.selectedCategory!.name {
|
||||
Image(systemName: "book")
|
||||
} else {
|
||||
Image(systemName: "book.closed.fill")
|
||||
}
|
||||
|
||||
if category.name == "*" {
|
||||
Text("Other")
|
||||
.font(.system(size: 20, weight: .medium, design: .default))
|
||||
} else {
|
||||
Text(category.name)
|
||||
.font(.system(size: 20, weight: .medium, design: .default))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text("\(category.recipe_count)")
|
||||
.font(.system(size: 15, weight: .bold, design: .default))
|
||||
.foregroundStyle(Color.background)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.minimumScaleFactor(0.5)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundStyle(Color.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Recently Viewed
|
||||
if !appState.recentRecipes.isEmpty {
|
||||
RecentRecipesSection()
|
||||
}
|
||||
|
||||
// Categories header
|
||||
if !appState.categories.isEmpty {
|
||||
Text("Categories")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Category grid
|
||||
if appState.categories.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No cookbooks found")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Pull to refresh or check your server connection.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ForEach(appState.categories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
if horizontalSizeClass == .compact {
|
||||
viewModel.navigateToCategory = true
|
||||
}
|
||||
} label: {
|
||||
CategoryCardView(
|
||||
category: category,
|
||||
isSelected: viewModel.selectedCategory?.name == category.name
|
||||
)
|
||||
}
|
||||
}.padding(7)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Cookbooks")
|
||||
.navigationTitle("Recipes")
|
||||
.toolbar {
|
||||
RecipeTabViewToolBar()
|
||||
}
|
||||
@@ -64,6 +86,22 @@ struct RecipeTabView: View {
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
.navigationDestination(isPresented: $viewModel.navigateToCategory) {
|
||||
if let category = viewModel.selectedCategory {
|
||||
RecipeListView(
|
||||
categoryName: category.name,
|
||||
showEditView: $viewModel.presentEditView
|
||||
)
|
||||
.id(category.id)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
if let category = viewModel.selectedCategory {
|
||||
@@ -71,9 +109,8 @@ struct RecipeTabView: View {
|
||||
categoryName: category.name,
|
||||
showEditView: $viewModel.presentEditView
|
||||
)
|
||||
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
||||
.id(category.id)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.tint(.nextcloudBlue)
|
||||
@@ -89,17 +126,22 @@ struct RecipeTabView: View {
|
||||
viewModel.serverConnection = connection
|
||||
}
|
||||
await appState.getCategories()
|
||||
for category in appState.categories {
|
||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||
await appState.getCategoryImage(for: category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var presentEditView: Bool = false
|
||||
@Published var presentSettingsView: Bool = false
|
||||
|
||||
@Published var navigateToCategory: Bool = false
|
||||
|
||||
@Published var presentLoadingIndicator: Bool = false
|
||||
@Published var presentConnectionPopover: Bool = false
|
||||
@Published var serverConnection: Bool = false
|
||||
|
||||
|
||||
@Published var selectedCategory: Category? = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,113 @@ import SwiftUI
|
||||
struct SearchTabView: View {
|
||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
.background(
|
||||
NavigationLink(value: recipe) {
|
||||
EmptyView()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.frame(height: 85)
|
||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.listRowSeparatorTint(.clear)
|
||||
.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)
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
}
|
||||
.navigationTitle("Search recipe")
|
||||
.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 {
|
||||
@@ -46,29 +128,119 @@ struct SearchTabView: View {
|
||||
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 {
|
||||
guard searchText != "" else { return allRecipes }
|
||||
return allRecipes.filter { recipe in
|
||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user