Fix settings page dismissing immediately by replacing multiple isPresented navigation destinations with value-based NavigationPath
The settings view was being popped immediately after push because multiple navigationDestination(isPresented:) modifiers on the same view caused SwiftUI to reset bindings when appState published changes. Replaced with a single navigationDestination(for: SidebarDestination.self) using an explicit NavigationStack(path:). Also fixed @ObservedObject -> @StateObject on SettingsView.ViewModel, added AllRecipesListView/AllRecipesCategoryCardView, and added translations for new strings. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@
|
|||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
||||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.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 */; };
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.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 = "<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>"; };
|
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>"; };
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesCategoryCardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesListView.swift; sourceTree = "<group>"; };
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.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>"; };
|
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>"; };
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
||||||
@@ -386,6 +390,8 @@
|
|||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
||||||
|
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */,
|
||||||
|
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */,
|
||||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||||
@@ -604,6 +610,8 @@
|
|||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
||||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
||||||
|
B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */,
|
||||||
|
B1C0DE082CF0000400000004 /* AllRecipesListView.swift in Sources */,
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||||
|
|||||||
@@ -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." : {
|
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"No results found" : {
|
||||||
"comment" : "A message indicating that no recipes were found for the current search query.",
|
"comment" : "A message indicating that no recipes were found for the current search query.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|||||||
@@ -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<Int> = []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
@ObservedObject var viewModel = ViewModel()
|
@StateObject var viewModel = ViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|||||||
@@ -18,8 +18,24 @@ struct RecipeTabView: View {
|
|||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private var showEditViewBinding: Binding<Bool> {
|
||||||
|
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 {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
|
NavigationStack(path: $viewModel.sidebarPath) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
// Recently Viewed
|
// Recently Viewed
|
||||||
@@ -28,7 +44,7 @@ struct RecipeTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Categories header
|
// Categories header
|
||||||
if !appState.categories.isEmpty {
|
if !nonEmptyCategories.isEmpty {
|
||||||
Text("Categories")
|
Text("Categories")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.bold()
|
.bold()
|
||||||
@@ -36,7 +52,7 @@ struct RecipeTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Category grid
|
// Category grid
|
||||||
if appState.categories.isEmpty {
|
if nonEmptyCategories.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "book.closed")
|
Image(systemName: "book.closed")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
@@ -53,16 +69,28 @@ struct RecipeTabView: View {
|
|||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
ForEach(appState.categories) { category in
|
// All Recipes card
|
||||||
|
if totalRecipeCount > 0 {
|
||||||
|
Button {
|
||||||
|
viewModel.navigateToAllRecipes()
|
||||||
|
} label: {
|
||||||
|
AllRecipesCategoryCardView()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(nonEmptyCategories) { category in
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedCategory = category
|
|
||||||
if horizontalSizeClass == .compact {
|
if horizontalSizeClass == .compact {
|
||||||
viewModel.navigateToCategory = true
|
viewModel.navigateToCategory(category)
|
||||||
|
} else {
|
||||||
|
viewModel.selectedCategory = category
|
||||||
|
viewModel.showAllRecipesInDetail = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
CategoryCardView(
|
CategoryCardView(
|
||||||
category: category,
|
category: category,
|
||||||
isSelected: viewModel.selectedCategory?.name == category.name
|
isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -77,37 +105,43 @@ struct RecipeTabView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
RecipeTabViewToolBar()
|
RecipeTabViewToolBar()
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
|
.navigationDestination(for: SidebarDestination.self) { destination in
|
||||||
|
switch destination {
|
||||||
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
}
|
case .newRecipe:
|
||||||
.navigationDestination(isPresented: $viewModel.presentEditView) {
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel())
|
RecipeView(viewModel: RecipeView.ViewModel())
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.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: {
|
} detail: {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let category = viewModel.selectedCategory {
|
if viewModel.showAllRecipesInDetail {
|
||||||
|
AllRecipesListView(showEditView: showEditViewBinding)
|
||||||
|
} else if let category = viewModel.selectedCategory {
|
||||||
RecipeListView(
|
RecipeListView(
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
showEditView: $viewModel.presentEditView
|
showEditView: showEditViewBinding
|
||||||
)
|
)
|
||||||
.id(category.id)
|
.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 {
|
class ViewModel: ObservableObject {
|
||||||
@Published var presentEditView: Bool = false
|
@Published var sidebarPath = NavigationPath()
|
||||||
@Published var presentSettingsView: Bool = false
|
|
||||||
@Published var navigateToCategory: Bool = false
|
|
||||||
|
|
||||||
@Published var presentLoadingIndicator: Bool = false
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
@Published var presentConnectionPopover: Bool = false
|
@Published var presentConnectionPopover: Bool = false
|
||||||
@Published var serverConnection: Bool = false
|
@Published var serverConnection: Bool = false
|
||||||
|
|
||||||
@Published var selectedCategory: Category? = nil
|
@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 {
|
Button {
|
||||||
viewModel.presentSettingsView = true
|
viewModel.navigateToSettings()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
@@ -214,7 +274,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
Logger.view.debug("Add new recipe")
|
Logger.view.debug("Add new recipe")
|
||||||
viewModel.presentEditView = true
|
viewModel.navigateToNewRecipe()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user