Add category and recipe sorting with multiple modes and order inversion

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>
This commit is contained in:
2026-02-15 07:46:23 +01:00
parent fb6b16c1fc
commit 5307b502e9
10 changed files with 694 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ 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] = []
@@ -16,6 +17,10 @@ struct AllRecipesListView: View {
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()
@@ -69,6 +74,9 @@ struct AllRecipesListView: View {
.environmentObject(mealPlan)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
recipeSortMenu
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
@@ -94,11 +102,83 @@ struct AllRecipesListView: View {
}
}
private func recipesFiltered() -> [Recipe] {
guard !searchText.isEmpty else { return allRecipes }
return allRecipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) ||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
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
}
}

View File

@@ -0,0 +1,89 @@
//
// CategoryReorderSheet.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct CategoryReorderSheet: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
private static let allRecipesSentinel = "__ALL_RECIPES__"
@State private var orderedNames: [String] = []
var body: some View {
NavigationStack {
List {
ForEach(orderedNames, id: \.self) { name in
HStack {
if name == Self.allRecipesSentinel {
Text("All Recipes")
.bold()
} else {
Text(name)
}
Spacer()
if name == Self.allRecipesSentinel {
let total = appState.categories.reduce(0) { $0 + $1.recipe_count }
Text("\(total)")
.foregroundStyle(.secondary)
.font(.subheadline)
} else if let count = appState.categories.first(where: { $0.name == name })?.recipe_count {
Text("\(count)")
.foregroundStyle(.secondary)
.font(.subheadline)
}
}
}
.onMove { from, to in
orderedNames.move(fromOffsets: from, toOffset: to)
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle(String(localized: "Reorder Categories"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "Done")) {
appState.updateManualCategoryOrder(orderedNames)
dismiss()
}
.bold()
}
}
}
.onAppear {
let currentCategoryNames = appState.categories
.filter { $0.recipe_count > 0 }
.map { $0.name }
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
let existing = appState.manualCategoryOrder
// Keep only names that still exist on the server (or are the sentinel)
var reconciled = existing.filter {
$0 == Self.allRecipesSentinel || currentCategoryNames.contains($0)
}
// Ensure the All Recipes sentinel is present
if totalCount > 0 && !reconciled.contains(Self.allRecipesSentinel) {
reconciled.insert(Self.allRecipesSentinel, at: 0)
}
// Append any new categories not yet in the manual order
for name in currentCategoryNames where !reconciled.contains(name) {
reconciled.append(name)
}
orderedNames = reconciled
}
}
}

View File

@@ -14,6 +14,7 @@ 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
@@ -22,6 +23,10 @@ struct RecipeListView: View {
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()
@@ -82,6 +87,9 @@ struct RecipeListView: View {
.environmentObject(mealPlan)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
recipeSortMenu
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
@@ -113,12 +121,84 @@ struct RecipeListView: View {
}
}
func recipesFiltered() -> [Recipe] {
guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
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
}
}