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:
2026-02-15 01:47:16 +01:00
parent 7c824b492e
commit c8ddb098d1
11 changed files with 792 additions and 121 deletions

View File

@@ -48,7 +48,15 @@ struct MainView: View {
recipeViewModel.presentLoadingIndicator = true
await appState.getCategories()
await appState.updateAllRecipeDetails()
// Preload category images
for category in appState.categories {
await appState.getCategoryImage(for: category.name)
}
// Load recently viewed recipes
await appState.loadRecentRecipes()
// Open detail view for default category
if UserSettings.shared.defaultCategory != "" {
if let cat = appState.categories.first(where: { c in

View File

@@ -0,0 +1,82 @@
//
// CategoryCardView.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct CategoryCardView: View {
@EnvironmentObject var appState: AppState
let category: Category
var isSelected: Bool = false
@State private var imageLoaded = false
private var displayName: String {
category.name == "*" ? String(localized: "Other") : category.name
}
var body: some View {
ZStack(alignment: .bottomLeading) {
// Background image or gradient fallback
if let image = appState.categoryImages[category.name] {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
.clipped()
.opacity(imageLoaded ? 1 : 0)
.animation(.easeIn(duration: 0.3), value: imageLoaded)
.onAppear { imageLoaded = true }
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
.overlay(alignment: .center) {
Image(systemName: "book.closed.fill")
.font(.system(size: 36))
.foregroundStyle(.white.opacity(0.5))
}
}
// Bottom scrim with text
VStack(alignment: .leading, spacing: 2) {
Spacer()
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 60)
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 2) {
Text(displayName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
Text("\(category.recipe_count) recipes")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 17))
.overlay(
RoundedRectangle(cornerRadius: 17)
.stroke(isSelected ? Color.nextcloudBlue : .clear, lineWidth: 3)
)
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
.task {
if appState.categoryImages[category.name] == nil {
await appState.getCategoryImage(for: category.name)
}
}
}
}

View File

@@ -0,0 +1,110 @@
//
// RecentRecipesSection.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct RecentRecipesSection: View {
@EnvironmentObject var appState: AppState
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recently Viewed")
.font(.title2)
.bold()
Spacer()
Button {
appState.clearRecentRecipes()
} label: {
Text("Clear")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(appState.recentRecipes) { recipe in
NavigationLink(value: recipe) {
RecentRecipeCard(recipe: recipe)
.environmentObject(appState)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
}
}
private struct RecentRecipeCard: View {
@EnvironmentObject var appState: AppState
let recipe: Recipe
@State private var thumbnail: 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 {
VStack(alignment: .leading, spacing: 0) {
// Thumbnail
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 160, height: 120)
.clipped()
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 160, height: 120)
.overlay {
Image(systemName: "fork.knife")
.font(.title2)
.foregroundStyle(.white.opacity(0.6))
}
}
// Text content
VStack(alignment: .leading, spacing: 2) {
Text(recipe.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.leading)
.foregroundStyle(.primary)
if let keywordsText {
Text(keywordsText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.frame(width: 160)
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
.task {
thumbnail = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
)
}
}
}

View File

@@ -12,52 +12,64 @@ struct RecipeCardView: View {
@EnvironmentObject var appState: AppState
@State var recipe: Recipe
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
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 {
VStack(alignment: .leading, spacing: 0) {
// Thumbnail
if let recipeThumb = recipeThumb {
Image(uiImage: recipeThumb)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
.clipped()
} else {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
.background(Color("ncblue"))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
}
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer()
if let isDownloaded = isDownloaded {
VStack {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
.overlay {
Image(systemName: "fork.knife")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
}
// Text content
VStack(alignment: .leading, spacing: 3) {
Text(recipe.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.leading)
if let keywordsText {
Text(keywordsText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
.task {
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
)
if recipe.storedLocally == nil {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
}
isDownloaded = recipe.storedLocally
}
.refreshable {
recipeThumb = await appState.getImage(
@@ -66,6 +78,5 @@ struct RecipeCardView: View {
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
)
}
.frame(height: 80)
}
}

View File

@@ -10,7 +10,6 @@ import SwiftUI
struct RecipeListView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@@ -18,45 +17,62 @@ struct RecipeListView: View {
@State var searchText: String = ""
@Binding var showEditView: Bool
@State var selectedRecipe: Recipe? = nil
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
var body: some View {
Group {
let recipes = recipesFiltered()
if !recipes.isEmpty {
List(recipesFiltered(), id: \.recipe_id) { recipe in
RecipeCardView(recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink(value: recipe) {
EmptyView()
}
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("\(recipes.count) recipes")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal)
LazyVGrid(columns: gridColumns, spacing: 12) {
ForEach(recipes, id: \.recipe_id) { recipe in
NavigationLink(value: recipe) {
RecipeCardView(recipe: recipe)
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
}
.padding(.horizontal)
}
.padding(.vertical)
}
.listStyle(.plain)
} else {
VStack {
Text("There are no recipes in this cookbook!")
VStack(spacing: 16) {
Image(systemName: "fork.knife")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No recipes in this cookbook")
.font(.headline)
.foregroundStyle(.secondary)
Text("Recipes will appear here once they are added to this category.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
Task {
await appState.getCategories()
await appState.getCategory(named: categoryName, fetchMode: .preferServer)
}
} label: {
Text("Refresh")
Label("Refresh", systemImage: "arrow.clockwise")
.bold()
}
.buttonStyle(.bordered)
.tint(.nextcloudBlue)
}.padding()
}
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState)
@@ -84,7 +100,7 @@ struct RecipeListView: View {
)
}
}
func recipesFiltered() -> [Recipe] {
guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes }

View File

@@ -167,7 +167,10 @@ struct RecipeView: View {
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error
viewModel.setupView(recipeDetail: recipeDetail)
// Track as recently viewed
appState.addToRecentRecipes(viewModel.recipe)
// Show download badge
if viewModel.recipe.storedLocally == nil {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)

View File

@@ -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
}
}

View File

@@ -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
)
}
}
}