// // 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 @ObservedObject private var userSettings = UserSettings.shared @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var showManualReorderSheet = false private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] private static let allRecipesSentinel = "__ALL_RECIPES__" private var allCategoryNames: [String] { let names = appState.categories.filter { $0.recipe_count > 0 }.map { $0.name } let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count } guard totalCount > 0 else { return names } return [Self.allRecipesSentinel] + names } private var sortedCategoryNames: [String] { let names = allCategoryNames guard let mode = CategorySortMode(rawValue: userSettings.categorySortMode) else { return names } let ascending = userSettings.categorySortAscending switch mode { case .recentlyUsed: return names.sorted { a, b in let dateA = appState.categoryAccessDates[a] ?? .distantPast let dateB = appState.categoryAccessDates[b] ?? .distantPast return ascending ? dateA > dateB : dateA < dateB } case .alphabetical: return names.sorted { a, b in let nameA = a == Self.allRecipesSentinel ? String(localized: "All Recipes") : a let nameB = b == Self.allRecipesSentinel ? String(localized: "All Recipes") : b let result = nameA.localizedCaseInsensitiveCompare(nameB) return ascending ? result == .orderedAscending : result == .orderedDescending } case .manual: let order = appState.manualCategoryOrder return names.sorted { a, b in let indexA = order.firstIndex(of: a) ?? Int.max let indexB = order.firstIndex(of: b) ?? Int.max return indexA < indexB } } } private var hasCategories: Bool { appState.categories.contains { $0.recipe_count > 0 } } private var currentSortMode: CategorySortMode { CategorySortMode(rawValue: userSettings.categorySortMode) ?? .recentlyUsed } private var categorySortMenu: some View { Menu { ForEach(CategorySortMode.allCases, id: \.self) { mode in Button { userSettings.categorySortMode = mode.rawValue userSettings.categorySortAscending = true if mode == .manual && appState.manualCategoryOrder.isEmpty { appState.updateManualCategoryOrder(allCategoryNames) } } label: { if currentSortMode == mode { Label(mode.descriptor(), systemImage: "checkmark") } else { Text(mode.descriptor()) } } } if currentSortMode.supportsInvert { Divider() Button { userSettings.categorySortAscending.toggle() } label: { Label( userSettings.categorySortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"), systemImage: userSettings.categorySortAscending ? "arrow.up.arrow.down" : "arrow.up.arrow.down" ) } } if currentSortMode == .manual { Divider() Button { showManualReorderSheet = true } label: { Label(String(localized: "Edit Order"), systemImage: "arrow.up.arrow.down.circle") } } } label: { Image(systemName: "arrow.up.arrow.down") .font(.subheadline) .foregroundStyle(.secondary) } } var body: some View { NavigationSplitView { NavigationStack(path: $viewModel.sidebarPath) { ScrollView { VStack(alignment: .leading, spacing: 20) { // Recently Viewed if !appState.recentRecipes.isEmpty { RecentRecipesSection() } // Categories header with sort button if hasCategories { HStack { Text("Categories") .font(.title2) .bold() Spacer() categorySortMenu } .padding(.horizontal) } // Category grid if !hasCategories { 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(sortedCategoryNames, id: \.self) { name in if name == Self.allRecipesSentinel { Button { appState.trackCategoryAccess(Self.allRecipesSentinel) viewModel.navigateToAllRecipes() } label: { AllRecipesCategoryCardView() } .buttonStyle(.plain) } else if let category = appState.categories.first(where: { $0.name == name && $0.recipe_count > 0 }) { Button { appState.trackCategoryAccess(category.name) 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) } } } .sheet(isPresented: $viewModel.showImportURLSheet, onDismiss: { viewModel.pendingImportURL = nil }) { ImportURLSheet( onImport: { recipeDetail in viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail) }, initialURL: viewModel.pendingImportURL ?? "" ) .environmentObject(appState) } .sheet(isPresented: $showManualReorderSheet) { CategoryReorderSheet() .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) } if UserSettings.shared.mealPlanSyncEnabled { await mealPlan.syncManager?.performSync() } } } 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 @Published var pendingImportURL: String? = 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 @EnvironmentObject var mealPlan: MealPlanManager 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() if UserSettings.shared.mealPlanSyncEnabled { await mealPlan.syncManager?.performSync() } 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") } } } }