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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user