Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Hendrik Hogertz 02118e3d7a Add dark mode support with appearance picker and fix hardcoded colors
Add user-facing appearance setting (System/Light/Dark) wired via
preferredColorScheme at the app root. Replace hardcoded .black tints
and foreground styles with .primary so toolbar buttons and text remain
visible in dark mode. Remove profile picture from settings and
SwiftSoup from acknowledgements.

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

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