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

@@ -15,16 +15,103 @@ struct RecipeTabView: View {
@EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
@ObservedObject private var userSettings = UserSettings.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var showManualReorderSheet = false
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
private var nonEmptyCategories: [Category] {
appState.categories.filter { $0.recipe_count > 0 }
private static let allRecipesSentinel = "__ALL_RECIPES__"
private var allCategoryNames: [String] {
let names = appState.categories.filter { $0.recipe_count > 0 }.map { $0.name }
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
guard totalCount > 0 else { return names }
return [Self.allRecipesSentinel] + names
}
private var totalRecipeCount: Int {
appState.categories.reduce(0) { $0 + $1.recipe_count }
private var sortedCategoryNames: [String] {
let names = allCategoryNames
guard let mode = CategorySortMode(rawValue: userSettings.categorySortMode) else {
return names
}
let ascending = userSettings.categorySortAscending
switch mode {
case .recentlyUsed:
return names.sorted { a, b in
let dateA = appState.categoryAccessDates[a] ?? .distantPast
let dateB = appState.categoryAccessDates[b] ?? .distantPast
return ascending ? dateA > dateB : dateA < dateB
}
case .alphabetical:
return names.sorted { a, b in
let nameA = a == Self.allRecipesSentinel ? String(localized: "All Recipes") : a
let nameB = b == Self.allRecipesSentinel ? String(localized: "All Recipes") : b
let result = nameA.localizedCaseInsensitiveCompare(nameB)
return ascending ? result == .orderedAscending : result == .orderedDescending
}
case .manual:
let order = appState.manualCategoryOrder
return names.sorted { a, b in
let indexA = order.firstIndex(of: a) ?? Int.max
let indexB = order.firstIndex(of: b) ?? Int.max
return indexA < indexB
}
}
}
private var hasCategories: Bool {
appState.categories.contains { $0.recipe_count > 0 }
}
private var currentSortMode: CategorySortMode {
CategorySortMode(rawValue: userSettings.categorySortMode) ?? .recentlyUsed
}
private var categorySortMenu: some View {
Menu {
ForEach(CategorySortMode.allCases, id: \.self) { mode in
Button {
userSettings.categorySortMode = mode.rawValue
userSettings.categorySortAscending = true
if mode == .manual && appState.manualCategoryOrder.isEmpty {
appState.updateManualCategoryOrder(allCategoryNames)
}
} label: {
if currentSortMode == mode {
Label(mode.descriptor(), systemImage: "checkmark")
} else {
Text(mode.descriptor())
}
}
}
if currentSortMode.supportsInvert {
Divider()
Button {
userSettings.categorySortAscending.toggle()
} label: {
Label(
userSettings.categorySortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
systemImage: userSettings.categorySortAscending ? "arrow.up.arrow.down" : "arrow.up.arrow.down"
)
}
}
if currentSortMode == .manual {
Divider()
Button {
showManualReorderSheet = true
} label: {
Label(String(localized: "Edit Order"), systemImage: "arrow.up.arrow.down.circle")
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
var body: some View {
@@ -37,16 +124,20 @@ struct RecipeTabView: View {
RecentRecipesSection()
}
// Categories header
if !nonEmptyCategories.isEmpty {
Text("Categories")
.font(.title2)
.bold()
.padding(.horizontal)
// Categories header with sort button
if hasCategories {
HStack {
Text("Categories")
.font(.title2)
.bold()
Spacer()
categorySortMenu
}
.padding(.horizontal)
}
// Category grid
if nonEmptyCategories.isEmpty {
if !hasCategories {
VStack(spacing: 12) {
Image(systemName: "book.closed")
.font(.system(size: 48))
@@ -63,31 +154,32 @@ struct RecipeTabView: View {
.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
ForEach(sortedCategoryNames, id: \.self) { name in
if name == Self.allRecipesSentinel {
Button {
appState.trackCategoryAccess(Self.allRecipesSentinel)
viewModel.navigateToAllRecipes()
} label: {
AllRecipesCategoryCardView()
}
} label: {
CategoryCardView(
category: category,
isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name
)
.buttonStyle(.plain)
} else if let category = appState.categories.first(where: { $0.name == name && $0.recipe_count > 0 }) {
Button {
appState.trackCategoryAccess(category.name)
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)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
@@ -169,6 +261,10 @@ struct RecipeTabView: View {
}
.environmentObject(appState)
}
.sheet(isPresented: $showManualReorderSheet) {
CategoryReorderSheet()
.environmentObject(appState)
}
.task {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
@@ -240,7 +336,7 @@ struct RecipeTabView: View {
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) {
@@ -260,7 +356,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
Text("Refresh all")
Image(systemName: "icloud.and.arrow.down")
}
Button {
viewModel.navigateToSettings()
} label: {
@@ -271,7 +367,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
Image(systemName: "ellipsis.circle")
}
}
// Server connection indicator
ToolbarItem(placement: .topBarTrailing) {
Button {