diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 3dcc0b2..a47c0fe 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -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 = ""; }; A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = ""; }; A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = ""; }; + B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = ""; }; + B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = ""; }; A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = ""; }; A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Nextcloud Cookbook iOS Client/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift index d28d9fc..c44ec92 100644 --- a/Nextcloud Cookbook iOS Client/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -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() { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index f20b300..21591c8 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -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" } \ No newline at end of file diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 329d39e..a880736 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -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 diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryCardView.swift new file mode 100644 index 0000000..bd74f97 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryCardView.swift @@ -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) + } + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecentRecipesSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecentRecipesSection.swift new file mode 100644 index 0000000..1f9a082 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecentRecipesSection.swift @@ -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 + ) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift index 8a6a880..b508f02 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift @@ -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) } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 6299a35..29af8a8 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -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 } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 6668e8f..548465f 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -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) diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 382b68f..ad92d84 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -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 } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift index 0a127a9..d448069 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -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 + ) + } } }