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>
324 lines
13 KiB
Swift
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")
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|