Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Hendrik Hogertz 1f7f19c74b Fix meal plan not populating on first login and add pull-to-refresh sync
Rewrite MealPlanSyncManager.performSync() (renamed from performInitialSync) to
discover _mealPlanAssignment metadata from all server recipes, not just locally-
known ones. On first sync all recipes are checked; on subsequent syncs only
recipes modified since lastMealPlanSyncDate are fetched (max 5 concurrent).

Trigger meal plan sync from pull-to-refresh on both the recipe and meal plan
tabs, and from the "Refresh all" toolbar button.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 11:40:31 +01:00

432 lines
18 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
@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")
}
}
}
}