Introduces weekly meal planning with a calendar-based tab view, per-recipe date assignments synced via Nextcloud Cookbook custom metadata, and 30-day automatic pruning of old entries on load, save, and sync merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
324 lines
13 KiB
Swift
324 lines
13 KiB
Swift
//
|
|
// RecipeTabView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 23.01.24.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
import SwiftUI
|
|
|
|
|
|
struct RecipeTabView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@EnvironmentObject var groceryList: GroceryListManager
|
|
@EnvironmentObject var mealPlan: MealPlanManager
|
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
|
|
|
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 {
|
|
NavigationStack(path: $viewModel.sidebarPath) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
// Recently Viewed
|
|
if !appState.recentRecipes.isEmpty {
|
|
RecentRecipesSection()
|
|
}
|
|
|
|
// 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(.vertical)
|
|
}
|
|
.navigationTitle("Recipes")
|
|
.toolbar {
|
|
RecipeTabViewToolBar()
|
|
}
|
|
.navigationDestination(for: SidebarDestination.self) { destination in
|
|
switch destination {
|
|
case .settings:
|
|
SettingsView()
|
|
.environmentObject(appState)
|
|
.environmentObject(groceryList)
|
|
case .newRecipe:
|
|
RecipeView(viewModel: {
|
|
let vm = RecipeView.ViewModel()
|
|
if let imported = viewModel.importedRecipeDetail {
|
|
vm.preloadedRecipeDetail = imported
|
|
}
|
|
return vm
|
|
}())
|
|
.environmentObject(appState)
|
|
.environmentObject(groceryList)
|
|
.environmentObject(mealPlan)
|
|
.onAppear {
|
|
viewModel.importedRecipeDetail = nil
|
|
}
|
|
case .category(let category):
|
|
RecipeListView(
|
|
categoryName: category.name,
|
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
)
|
|
.id(category.id)
|
|
.environmentObject(appState)
|
|
.environmentObject(groceryList)
|
|
.environmentObject(mealPlan)
|
|
case .allRecipes:
|
|
AllRecipesListView(
|
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
)
|
|
.environmentObject(appState)
|
|
.environmentObject(groceryList)
|
|
.environmentObject(mealPlan)
|
|
}
|
|
}
|
|
.navigationDestination(for: Recipe.self) { recipe in
|
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
|
.environmentObject(appState)
|
|
.environmentObject(groceryList)
|
|
.environmentObject(mealPlan)
|
|
}
|
|
}
|
|
} detail: {
|
|
NavigationStack {
|
|
if viewModel.showAllRecipesInDetail {
|
|
AllRecipesListView(
|
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
)
|
|
} else if let category = viewModel.selectedCategory {
|
|
RecipeListView(
|
|
categoryName: category.name,
|
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
)
|
|
.id(category.id)
|
|
}
|
|
}
|
|
}
|
|
.tint(.nextcloudBlue)
|
|
.sheet(isPresented: $viewModel.showImportURLSheet) {
|
|
ImportURLSheet { recipeDetail in
|
|
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)
|
|
}
|
|
.environmentObject(appState)
|
|
}
|
|
.task {
|
|
let connection = await appState.checkServerConnection()
|
|
DispatchQueue.main.async {
|
|
viewModel.serverConnection = connection
|
|
}
|
|
}
|
|
.refreshable {
|
|
let connection = await appState.checkServerConnection()
|
|
DispatchQueue.main.async {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SidebarDestination: Hashable {
|
|
case settings
|
|
case newRecipe
|
|
case category(Category)
|
|
case allRecipes
|
|
}
|
|
|
|
class ViewModel: ObservableObject {
|
|
@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
|
|
|
|
@Published var showImportURLSheet: Bool = false
|
|
@Published var importedRecipeDetail: RecipeDetail? = nil
|
|
|
|
func navigateToSettings() {
|
|
sidebarPath.append(SidebarDestination.settings)
|
|
}
|
|
|
|
func navigateToNewRecipe() {
|
|
sidebarPath.append(SidebarDestination.newRecipe)
|
|
}
|
|
|
|
func navigateToImportedRecipe(recipeDetail: RecipeDetail) {
|
|
importedRecipeDetail = recipeDetail
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|
@EnvironmentObject var appState: AppState
|
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
|
|
|
var body: some ToolbarContent {
|
|
// Top left menu toolbar item
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Menu {
|
|
Button {
|
|
Task {
|
|
viewModel.presentLoadingIndicator = true
|
|
UserSettings.shared.lastUpdate = Date.distantPast
|
|
await appState.getCategories()
|
|
for category in appState.categories {
|
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
|
}
|
|
await appState.updateAllRecipeDetails()
|
|
viewModel.presentLoadingIndicator = false
|
|
}
|
|
} label: {
|
|
Text("Refresh all")
|
|
Image(systemName: "icloud.and.arrow.down")
|
|
}
|
|
|
|
Button {
|
|
viewModel.navigateToSettings()
|
|
} label: {
|
|
Text("Settings")
|
|
Image(systemName: "gearshape")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
|
|
// Server connection indicator
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
Logger.view.debug("Check server connection")
|
|
viewModel.presentConnectionPopover = true
|
|
} label: {
|
|
if viewModel.presentLoadingIndicator {
|
|
ProgressView()
|
|
} else if viewModel.serverConnection {
|
|
Image(systemName: "checkmark.icloud")
|
|
} else {
|
|
Image(systemName: "xmark.icloud")
|
|
}
|
|
}.popover(isPresented: $viewModel.presentConnectionPopover) {
|
|
VStack(alignment: .leading) {
|
|
Text(viewModel.serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
|
|
.bold()
|
|
|
|
Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.secondary)
|
|
}
|
|
.padding()
|
|
.presentationCompactAdaptation(.popover)
|
|
}
|
|
}
|
|
|
|
// Create new recipes
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Menu {
|
|
Button {
|
|
Logger.view.debug("Add new recipe")
|
|
viewModel.navigateToNewRecipe()
|
|
} label: {
|
|
Label("Create New Recipe", systemImage: "square.and.pencil")
|
|
}
|
|
Button {
|
|
viewModel.showImportURLSheet = true
|
|
} label: {
|
|
Label("Import from URL", systemImage: "link")
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|