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:
@@ -20,6 +20,8 @@
|
||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */; };
|
||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
||||
@@ -101,6 +103,8 @@
|
||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
||||
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
|
||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = "<group>"; };
|
||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
||||
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
||||
@@ -381,6 +385,8 @@
|
||||
children = (
|
||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||
@@ -596,6 +602,8 @@
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||
|
||||
@@ -16,6 +16,8 @@ import UIKit
|
||||
@Published var recipes: [String: [Recipe]] = [:]
|
||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||
@Published var timers: [String: RecipeTimer] = [:]
|
||||
@Published var categoryImages: [String: UIImage] = [:]
|
||||
@Published var recentRecipes: [Recipe] = []
|
||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||
var lastUpdates: [String: Date] = [:]
|
||||
@@ -304,6 +306,47 @@ import UIKit
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Category images
|
||||
|
||||
func getCategoryImage(for categoryName: String) async {
|
||||
guard categoryImages[categoryName] == nil else { return }
|
||||
// Ensure recipes for this category are loaded
|
||||
if self.recipes[categoryName] == nil || self.recipes[categoryName]!.isEmpty {
|
||||
await getCategory(named: categoryName, fetchMode: .preferLocal)
|
||||
}
|
||||
guard let recipes = self.recipes[categoryName], !recipes.isEmpty else { return }
|
||||
for recipe in recipes {
|
||||
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
||||
self.categoryImages[categoryName] = image
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent recipes
|
||||
|
||||
func addToRecentRecipes(_ recipe: Recipe) {
|
||||
recentRecipes.removeAll { $0.recipe_id == recipe.recipe_id }
|
||||
recentRecipes.insert(recipe, at: 0)
|
||||
if recentRecipes.count > 10 {
|
||||
recentRecipes = Array(recentRecipes.prefix(10))
|
||||
}
|
||||
Task {
|
||||
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecentRecipes() async {
|
||||
if let loaded: [Recipe] = await loadLocal(path: "recent_recipes.data") {
|
||||
self.recentRecipes = loaded
|
||||
}
|
||||
}
|
||||
|
||||
func clearRecentRecipes() {
|
||||
recentRecipes = []
|
||||
dataStore.delete(path: "recent_recipes.data")
|
||||
}
|
||||
|
||||
// MARK: - Data management
|
||||
|
||||
func deleteAllData() {
|
||||
|
||||
@@ -284,6 +284,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld recipes" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld Rezepte"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld Serving(s)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -401,7 +411,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Ein einfach zu verwendender PDF-Ersteller für Swift. Wird zum Erzeugen von Rezept-PDF-Dokumenten verwendet."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -467,7 +477,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Aktion verzögert"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -578,7 +588,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Eine HTML-Parsing- und Web-Scraping-Bibliothek für Swift. Wird zum Importieren von schema.org-Rezepten von Webseiten verwendet."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -753,6 +763,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Categories" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kategorien"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Category" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -843,6 +863,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Clear" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Löschen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Comma (e.g. 1,42)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -977,6 +1007,7 @@
|
||||
}
|
||||
},
|
||||
"Cookbooks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1329,6 +1360,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Downloaded" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Heruntergeladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Downloads" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -1440,6 +1482,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter a recipe name or keyword to get started." : {
|
||||
"comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Gib einen Rezeptnamen oder ein Stichwort ein, um loszulegen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2459,6 +2513,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No cookbooks found" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Kategorien gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No keywords." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2503,6 +2567,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No recipes in this cookbook" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Rezepte in dieser Kategorie"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No results found" : {
|
||||
"comment" : "A message indicating that no recipes were found for the current search query.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Ergebnisse gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"None" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2635,6 +2721,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"On server" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auf dem Server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Other" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2881,6 +2978,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pull to refresh or check your server connection." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zum Aktualisieren nach unten ziehen oder Serververbindung prüfen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recent searches" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Letzte Suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recently Viewed" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zuletzt angesehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipe" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2969,6 +3096,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipes will appear here once they are added to this category." : {
|
||||
"comment" : "A description of what will happen when a user adds a recipe to a category.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rezepte werden hier angezeigt, sobald sie dieser Kategorie hinzugefügt werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Refresh" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3080,6 +3219,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search for recipes" : {
|
||||
"comment" : "A prompt displayed when the search text is empty, encouraging the user to enter a search term.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rezepte suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search recipe" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3124,6 +3275,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search Results" : {
|
||||
"comment" : "The title of the view that lists search results for recipes.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Suchergebnisse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select a default cookbook" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3574,7 +3737,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "SwiftSoup"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -3681,6 +3844,7 @@
|
||||
}
|
||||
},
|
||||
"There are no recipes in this cookbook!" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -3931,7 +4095,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "TPPDF"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -3971,6 +4135,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Try a different search term." : {
|
||||
"comment" : "A message suggesting a different search term if no results are found.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Versuche einen anderen Suchbegriff."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unable to complete action." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -4305,5 +4481,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
@@ -49,6 +49,14 @@ struct MainView: View {
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
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(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()
|
||||
// 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
|
||||
@@ -19,38 +18,55 @@ struct RecipeListView: View {
|
||||
@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(
|
||||
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) {
|
||||
EmptyView()
|
||||
RecipeCardView(recipe: recipe)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.frame(height: 85)
|
||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.listRowSeparatorTint(.clear)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,9 @@ struct RecipeView: View {
|
||||
) ?? 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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
NavigationLink(value: category) {
|
||||
HStack(alignment: .center) {
|
||||
if viewModel.selectedCategory != nil &&
|
||||
category.name == viewModel.selectedCategory!.name {
|
||||
Image(systemName: "book")
|
||||
} else {
|
||||
Image(systemName: "book.closed.fill")
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
if horizontalSizeClass == .compact {
|
||||
viewModel.navigateToCategory = true
|
||||
}
|
||||
|
||||
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))
|
||||
} label: {
|
||||
CategoryCardView(
|
||||
category: category,
|
||||
isSelected: viewModel.selectedCategory?.name == category.name
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}.padding(7)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.navigationTitle("Cookbooks")
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.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,12 +126,17 @@ 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
|
||||
|
||||
@@ -14,10 +14,88 @@ struct SearchTabView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
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()
|
||||
@@ -25,17 +103,21 @@ struct SearchTabView: View {
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.frame(height: 85)
|
||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.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))
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
.onSubmit(of: .search) {
|
||||
viewModel.saveToHistory(viewModel.searchText)
|
||||
}
|
||||
.navigationTitle("Search recipe")
|
||||
}
|
||||
.task {
|
||||
if viewModel.allRecipes.isEmpty {
|
||||
@@ -51,24 +133,114 @@ struct SearchTabView: View {
|
||||
@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