Categories on the main page can be sorted by Recently Used, Alphabetical, or Manual (drag-to-reorder). The sort menu appears inline next to the Categories header. All Recipes is included in the sort order and manual reorder sheet. Recipes within category and all-recipes lists can be sorted by Recently Added or Alphabetical, with the sort button in the toolbar. All non-manual sort modes support order inversion via a Reverse/Default Order toggle. Date parsing handles both formatted strings and Unix timestamps, with recipe_id as fallback when dates are unavailable. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
185 lines
6.7 KiB
Swift
185 lines
6.7 KiB
Swift
//
|
|
// AllRecipesListView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AllRecipesListView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@EnvironmentObject var groceryList: GroceryListManager
|
|
@EnvironmentObject var mealPlan: MealPlanManager
|
|
@ObservedObject private var userSettings = UserSettings.shared
|
|
var onCreateNew: () -> Void
|
|
var onImportFromURL: () -> Void
|
|
@State private var allRecipes: [Recipe] = []
|
|
@State private var searchText: String = ""
|
|
|
|
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 found")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
Button {
|
|
Task {
|
|
allRecipes = await appState.getRecipes()
|
|
}
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
.bold()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.primary)
|
|
}.padding()
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
|
.navigationTitle(String(localized: "All Recipes"))
|
|
.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 {
|
|
allRecipes = await appState.getRecipes()
|
|
}
|
|
.refreshable {
|
|
allRecipes = await appState.getRecipes()
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
private func recipesFiltered() -> [Recipe] {
|
|
let filtered: [Recipe]
|
|
if searchText.isEmpty {
|
|
filtered = allRecipes
|
|
} else {
|
|
filtered = allRecipes.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
|
|
}
|
|
}
|