diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index a47c0fe..61b9957 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 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 */; }; + B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */; }; + B1C0DE082CF0000400000004 /* AllRecipesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE072CF0000400000004 /* AllRecipesListView.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 */; }; @@ -105,6 +107,8 @@ 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 = ""; }; + B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesCategoryCardView.swift; sourceTree = ""; }; + B1C0DE072CF0000400000004 /* AllRecipesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesListView.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 = ""; }; @@ -386,6 +390,8 @@ A70171BD2AB4987900064C43 /* RecipeListView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */, B1C0DE012CF0000100000001 /* CategoryCardView.swift */, + B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */, + B1C0DE072CF0000400000004 /* AllRecipesListView.swift */, B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */, A70171BF2AB498A900064C43 /* RecipeView.swift */, A97506112B920D8100E86029 /* RecipeViewSections */, @@ -604,6 +610,8 @@ A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */, B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */, + B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */, + B1C0DE082CF0000400000004 /* AllRecipesListView.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/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 21591c8..23ea179 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -583,6 +583,28 @@ } } }, + "All Recipes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Rezepte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todas las recetas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toutes les recettes" + } + } + } + }, "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { "localizations" : { "de" : { @@ -2577,6 +2599,28 @@ } } }, + "No recipes found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Rezepte gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron recetas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune recette trouvée" + } + } + } + }, "No results found" : { "comment" : "A message indicating that no recipes were found for the current search query.", "isCommentAutoGenerated" : true, diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesCategoryCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesCategoryCardView.swift new file mode 100644 index 0000000..3192667 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesCategoryCardView.swift @@ -0,0 +1,152 @@ +// +// AllRecipesCategoryCardView.swift +// Nextcloud Cookbook iOS Client +// + +import SwiftUI + +struct AllRecipesCategoryCardView: View { + @EnvironmentObject var appState: AppState + @State private var mosaicImages: [UIImage] = [] + + private var totalRecipeCount: Int { + appState.categories.reduce(0) { $0 + $1.recipe_count } + } + + var body: some View { + ZStack(alignment: .bottomLeading) { + // 2x2 image mosaic or gradient fallback + if mosaicImages.count >= 4 { + mosaicGrid + } else { + gradientFallback + } + + // Bottom scrim with text + VStack(alignment: .leading, spacing: 2) { + Spacer() + LinearGradient( + colors: [.clear, .black.opacity(0.95)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 60) + .overlay(alignment: .bottomLeading) { + VStack(alignment: .leading, spacing: 2) { + Text("All Recipes") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + Text("\(totalRecipeCount) 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)) + .shadow(color: .black.opacity(0.1), radius: 4, y: 2) + .task { + await loadMosaicImages() + } + } + + private func loadMosaicImages() async { + // Ensure recipes are loaded for each category (they may not be yet) + for category in appState.categories { + if appState.recipes[category.name] == nil || appState.recipes[category.name]!.isEmpty { + await appState.getCategory(named: category.name, fetchMode: .preferLocal) + } + } + + // Collect all recipes across categories, shuffled for variety + var allRecipes: [Recipe] = [] + for category in appState.categories { + if let recipes = appState.recipes[category.name] { + allRecipes.append(contentsOf: recipes) + } + } + allRecipes.shuffle() + + // Filter to recipes that have an image URL, then pick 4 + var candidates: [Recipe] = [] + var seenIds: Set = [] + for recipe in allRecipes { + guard let url = recipe.imageUrl, !url.isEmpty else { continue } + guard !seenIds.contains(recipe.recipe_id) else { continue } + seenIds.insert(recipe.recipe_id) + candidates.append(recipe) + if candidates.count >= 4 { break } + } + + var images: [UIImage] = [] + for recipe in candidates { + if let image = await appState.getImage( + id: recipe.recipe_id, + size: .THUMB, + fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer + ) { + images.append(image) + } + } + guard !images.isEmpty else { return } + // Cycle to fill 4 slots if fewer than 4 unique images + var filled: [UIImage] = [] + for i in 0..<4 { + filled.append(images[i % images.count]) + } + mosaicImages = filled + } + + private var mosaicGrid: some View { + VStack(spacing: 1) { + HStack(spacing: 1) { + imageCell(mosaicImages[safe: 0]) + imageCell(mosaicImages[safe: 1]) + } + HStack(spacing: 1) { + imageCell(mosaicImages[safe: 2]) + imageCell(mosaicImages[safe: 3]) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140) + .clipped() + } + + private func imageCell(_ image: UIImage?) -> some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipped() + } else { + Color.gray + } + } + } + + private var gradientFallback: some View { + LinearGradient( + gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140) + .overlay(alignment: .center) { + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 36)) + .foregroundStyle(.white.opacity(0.5)) + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift new file mode 100644 index 0000000..753da5d --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift @@ -0,0 +1,92 @@ +// +// AllRecipesListView.swift +// Nextcloud Cookbook iOS Client +// + +import SwiftUI + +struct AllRecipesListView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var groceryList: GroceryList + @Binding var showEditView: Bool + @State private var allRecipes: [Recipe] = [] + @State private var searchText: String = "" + + private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] + + var body: some View { + Group { + let recipes = recipesFiltered() + if !recipes.isEmpty { + 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) + } + } + .padding(.horizontal) + } + .padding(.vertical) + } + } else { + VStack(spacing: 16) { + Image(systemName: "fork.knife") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("No recipes found") + .font(.headline) + .foregroundStyle(.secondary) + Button { + Task { + allRecipes = await appState.getRecipes() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .bold() + } + .buttonStyle(.bordered) + .tint(.nextcloudBlue) + }.padding() + } + } + .searchable(text: $searchText, prompt: "Search recipes/keywords") + .navigationTitle(String(localized: "All Recipes")) + .navigationDestination(for: Recipe.self) { recipe in + RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) + .environmentObject(appState) + .environmentObject(groceryList) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showEditView = true + } label: { + Image(systemName: "plus.circle.fill") + } + } + } + .task { + allRecipes = await appState.getRecipes() + } + .refreshable { + allRecipes = await appState.getRecipes() + } + } + + private func recipesFiltered() -> [Recipe] { + guard !searchText.isEmpty else { return allRecipes } + return allRecipes.filter { recipe in + recipe.name.lowercased().contains(searchText.lowercased()) || + (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index cc72afc..8c688b6 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -14,7 +14,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState @ObservedObject var userSettings = UserSettings.shared - @ObservedObject var viewModel = ViewModel() + @StateObject var viewModel = ViewModel() var body: some View { Form { diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index ad92d84..3d040c8 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -18,96 +18,130 @@ struct RecipeTabView: View { private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] + private var showEditViewBinding: Binding { + Binding( + get: { false }, + set: { if $0 { viewModel.navigateToNewRecipe() } } + ) + } + + private var nonEmptyCategories: [Category] { + appState.categories.filter { $0.recipe_count > 0 } + } + + private var totalRecipeCount: Int { + appState.categories.reduce(0) { $0 + $1.recipe_count } + } + var body: some View { NavigationSplitView { - 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) + NavigationStack(path: $viewModel.sidebarPath) { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Recently Viewed + if !appState.recentRecipes.isEmpty { + RecentRecipesSection() } - .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 - ) - } - .buttonStyle(.plain) + + // Categories header + if !nonEmptyCategories.isEmpty { + Text("Categories") + .font(.title2) + .bold() + .padding(.horizontal) + } + + // Category grid + if nonEmptyCategories.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) { + // All Recipes card + if totalRecipeCount > 0 { + Button { + viewModel.navigateToAllRecipes() + } label: { + AllRecipesCategoryCardView() + } + .buttonStyle(.plain) + } + + ForEach(nonEmptyCategories) { category in + Button { + if horizontalSizeClass == .compact { + viewModel.navigateToCategory(category) + } else { + viewModel.selectedCategory = category + viewModel.showAllRecipesInDetail = false + } + } label: { + CategoryCardView( + category: category, + isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) } - .padding(.horizontal) + } + .padding(.vertical) + } + .navigationTitle("Recipes") + .toolbar { + RecipeTabViewToolBar() + } + .navigationDestination(for: SidebarDestination.self) { destination in + switch destination { + case .settings: + SettingsView() + .environmentObject(appState) + case .newRecipe: + RecipeView(viewModel: RecipeView.ViewModel()) + .environmentObject(appState) + .environmentObject(groceryList) + case .category(let category): + RecipeListView( + categoryName: category.name, + showEditView: showEditViewBinding + ) + .id(category.id) + .environmentObject(appState) + .environmentObject(groceryList) + case .allRecipes: + AllRecipesListView(showEditView: showEditViewBinding) + .environmentObject(appState) + .environmentObject(groceryList) } } - .padding(.vertical) - } - .navigationTitle("Recipes") - .toolbar { - RecipeTabViewToolBar() - } - .navigationDestination(isPresented: $viewModel.presentSettingsView) { - SettingsView() - .environmentObject(appState) - } - .navigationDestination(isPresented: $viewModel.presentEditView) { - RecipeView(viewModel: RecipeView.ViewModel()) - .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) + .navigationDestination(for: Recipe.self) { recipe in + RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) + .environmentObject(appState) + .environmentObject(groceryList) } } } detail: { NavigationStack { - if let category = viewModel.selectedCategory { + if viewModel.showAllRecipesInDetail { + AllRecipesListView(showEditView: showEditViewBinding) + } else if let category = viewModel.selectedCategory { RecipeListView( categoryName: category.name, - showEditView: $viewModel.presentEditView + showEditView: showEditViewBinding ) .id(category.id) } @@ -133,16 +167,42 @@ struct RecipeTabView: View { } } + enum SidebarDestination: Hashable { + case settings + case newRecipe + case category(Category) + case allRecipes + } + class ViewModel: ObservableObject { - @Published var presentEditView: Bool = false - @Published var presentSettingsView: Bool = false - @Published var navigateToCategory: Bool = false + @Published var sidebarPath = NavigationPath() @Published var presentLoadingIndicator: Bool = false @Published var presentConnectionPopover: Bool = false @Published var serverConnection: Bool = false @Published var selectedCategory: Category? = nil + @Published var showAllRecipesInDetail: Bool = false + + func navigateToSettings() { + sidebarPath.append(SidebarDestination.settings) + } + + func navigateToNewRecipe() { + sidebarPath.append(SidebarDestination.newRecipe) + } + + func navigateToCategory(_ category: Category) { + selectedCategory = category + showAllRecipesInDetail = false + sidebarPath.append(SidebarDestination.category(category)) + } + + func navigateToAllRecipes() { + selectedCategory = nil + showAllRecipesInDetail = true + sidebarPath.append(SidebarDestination.allRecipes) + } } } @@ -173,7 +233,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { } Button { - viewModel.presentSettingsView = true + viewModel.navigateToSettings() } label: { Text("Settings") Image(systemName: "gearshape") @@ -214,7 +274,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { Logger.view.debug("Add new recipe") - viewModel.presentEditView = true + viewModel.navigateToNewRecipe() } label: { Image(systemName: "plus.circle.fill") }