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