Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Hendrik Hogertz fb6b16c1fc Fix category handling, recipe management, and dark mode toggle tint
- Map uncategorized category between * (internal) and empty string
  (API) so selecting Sonstige/Other correctly persists to the server
- Default new recipes to Other (*) category and remove None option
- Add "New Category" option to category picker in recipe edit view
- Include newly created/imported recipes in recently viewed list and
  pre-fetch thumbnails so images display immediately
- Remove deleted recipes from recently viewed list
- Remove broad .tint(.primary) from RecipeTabView that caused white
  toggles in Settings during dark mode
- Rename German "Other" translation from Andere to Sonstige
- Add missing translations for Servings stepper and new category strings

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

323 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)
}
}
}
.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")
}
}
}
}