// // CategoryDetailView.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 15.09.23. // import Foundation import SwiftUI struct RecipeListView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryListManager @EnvironmentObject var mealPlan: MealPlanManager @ObservedObject private var userSettings = UserSettings.shared @State var categoryName: String @State var searchText: String = "" var onCreateNew: () -> Void var onImportFromURL: () -> Void @State var selectedRecipe: Recipe? = nil private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] private var currentRecipeSortMode: RecipeSortMode { RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded } var body: some View { Group { let recipes = recipesFiltered() if !recipes.isEmpty { ScrollView { VStack(alignment: .leading, spacing: 8) { Text("\(recipes.count) recipes") .font(.subheadline) .foregroundStyle(.secondary) .padding(.horizontal) LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(recipes, id: \.recipe_id) { recipe in NavigationLink(value: recipe) { RecipeCardView(recipe: recipe) } .buttonStyle(.plain) } } .padding(.horizontal) } .padding(.vertical) } } else { VStack(spacing: 16) { Image(systemName: "fork.knife") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("No recipes in this cookbook") .font(.headline) .foregroundStyle(.secondary) Text("Recipes will appear here once they are added to this category.") .font(.subheadline) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) .padding(.horizontal, 32) Button { Task { await appState.getCategories() await appState.getCategory(named: categoryName, fetchMode: .preferServer) } } label: { Label("Refresh", systemImage: "arrow.clockwise") .bold() } .buttonStyle(.bordered) .tint(.primary) }.padding() } } .searchable(text: $searchText, prompt: "Search recipes/keywords") .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .navigationDestination(for: Recipe.self) { recipe in RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) .environmentObject(appState) .environmentObject(groceryList) .environmentObject(mealPlan) } .toolbar { ToolbarItem(placement: .topBarTrailing) { recipeSortMenu } ToolbarItem(placement: .topBarTrailing) { Menu { Button { onCreateNew() } label: { Label("Create New Recipe", systemImage: "square.and.pencil") } Button { onImportFromURL() } label: { Label("Import from URL", systemImage: "link") } } label: { Image(systemName: "plus.circle.fill") } } } .task { await appState.getCategory( named: categoryName, fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer ) } .refreshable { await appState.getCategory( named: categoryName, fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer ) } } private var recipeSortMenu: some View { Menu { ForEach(RecipeSortMode.allCases, id: \.self) { mode in Button { userSettings.recipeSortMode = mode.rawValue userSettings.recipeSortAscending = true } label: { if currentRecipeSortMode == mode { Label(mode.descriptor(), systemImage: "checkmark") } else { Text(mode.descriptor()) } } } Divider() Button { userSettings.recipeSortAscending.toggle() } label: { Label( userSettings.recipeSortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"), systemImage: "arrow.up.arrow.down" ) } } label: { Image(systemName: "arrow.up.arrow.down") } } func recipesFiltered() -> [Recipe] { guard let recipes = appState.recipes[categoryName] else { return [] } let filtered: [Recipe] if searchText.isEmpty { filtered = recipes } else { filtered = recipes.filter { recipe in recipe.name.lowercased().contains(searchText.lowercased()) || (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) } } return sortRecipes(filtered) } private func sortRecipes(_ recipes: [Recipe]) -> [Recipe] { let mode = currentRecipeSortMode let ascending = userSettings.recipeSortAscending switch mode { case .recentlyAdded: return recipes.sorted { a, b in let dateA = parseDate(a.dateModified ?? a.dateCreated) ?? Date(timeIntervalSince1970: Double(a.recipe_id)) let dateB = parseDate(b.dateModified ?? b.dateCreated) ?? Date(timeIntervalSince1970: Double(b.recipe_id)) return ascending ? dateA > dateB : dateA < dateB } case .alphabetical: return recipes.sorted { a, b in let result = a.name.localizedCaseInsensitiveCompare(b.name) return ascending ? result == .orderedAscending : result == .orderedDescending } } } private static let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd HH:mm:ss" f.timeZone = TimeZone(secondsFromGMT: 0) return f }() private func parseDate(_ string: String?) -> Date? { guard let string, !string.isEmpty else { return nil } // Try "yyyy-MM-dd HH:mm:ss" first if let date = Self.dateFormatter.date(from: string) { return date } // Try Unix timestamp (integer string) if let timestamp = Double(string), timestamp > 0 { return Date(timeIntervalSince1970: timestamp) } return nil } }