From 5307b502e910cf046b57d2a287e345f970adc4d7 Mon Sep 17 00:00:00 2001 From: Hendrik Hogertz Date: Sun, 15 Feb 2026 07:46:23 +0100 Subject: [PATCH] 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 --- .../project.pbxproj | 8 + Nextcloud Cookbook iOS Client/AppState.swift | 30 +++ .../Data/CategorySortMode.swift | 55 ++++++ .../Data/UserSettings.swift | 28 +++ .../Localizable.xcstrings | 176 ++++++++++++++++++ .../Views/MainView.swift | 4 + .../Views/Recipes/AllRecipesListView.swift | 90 ++++++++- .../Views/Recipes/CategoryReorderSheet.swift | 89 +++++++++ .../Views/Recipes/RecipeListView.swift | 92 ++++++++- .../Views/Tabs/RecipeTabView.swift | 170 +++++++++++++---- 10 files changed, 694 insertions(+), 48 deletions(-) create mode 100644 Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift create mode 100644 Nextcloud Cookbook iOS Client/Views/Recipes/CategoryReorderSheet.swift diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index e993896..c35e636 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -80,6 +80,8 @@ F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; }; F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; }; F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */; }; + G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE002F0B000100000001 /* CategorySortMode.swift */; }; + G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -177,6 +179,8 @@ F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = ""; }; F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = ""; }; F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = ""; }; + G1A0CE002F0B000100000001 /* CategorySortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySortMode.swift; sourceTree = ""; }; + G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryReorderSheet.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -323,6 +327,7 @@ F1A0DE012E0C000100000001 /* MealPlanModels.swift */, F1A0DE032E0C000200000002 /* MealPlanManager.swift */, F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */, + G1A0CE002F0B000100000001 /* CategorySortMode.swift */, ); path = Data; sourceTree = ""; @@ -433,6 +438,7 @@ A97B4D342B80B82A00EC1A88 /* ShareView.swift */, C1F0AB012D0B000100000001 /* ImportURLSheet.swift */, F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */, + G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */, ); path = Recipes; sourceTree = ""; @@ -670,6 +676,8 @@ F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */, F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */, F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */, + G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */, + G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Nextcloud Cookbook iOS Client/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift index 8ea4a10..35b1bfb 100644 --- a/Nextcloud Cookbook iOS Client/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -18,6 +18,8 @@ import UIKit @Published var timers: [String: RecipeTimer] = [:] @Published var categoryImages: [String: UIImage] = [:] @Published var recentRecipes: [Recipe] = [] + @Published var categoryAccessDates: [String: Date] = [:] + @Published var manualCategoryOrder: [String] = [] var recipeImages: [Int: [String: UIImage]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] @@ -347,6 +349,34 @@ import UIKit dataStore.delete(path: "recent_recipes.data") } + // MARK: - Category sorting + + func trackCategoryAccess(_ categoryName: String) { + categoryAccessDates[categoryName] = Date() + Task { + await saveLocal(categoryAccessDates, path: "category_access_dates.data") + } + } + + func loadCategoryAccessDates() async { + if let loaded: [String: Date] = await loadLocal(path: "category_access_dates.data") { + self.categoryAccessDates = loaded + } + } + + func updateManualCategoryOrder(_ order: [String]) { + manualCategoryOrder = order + Task { + await saveLocal(manualCategoryOrder, path: "manual_category_order.data") + } + } + + func loadManualCategoryOrder() async { + if let loaded: [String] = await loadLocal(path: "manual_category_order.data") { + self.manualCategoryOrder = loaded + } + } + // MARK: - Data management func deleteAllData() { diff --git a/Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift b/Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift new file mode 100644 index 0000000..778d5d0 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift @@ -0,0 +1,55 @@ +// +// CategorySortMode.swift +// Nextcloud Cookbook iOS Client +// + +import Foundation + +enum CategorySortMode: String, CaseIterable { + case recentlyUsed = "recentlyUsed" + case alphabetical = "alphabetical" + case manual = "manual" + + func descriptor() -> String { + switch self { + case .recentlyUsed: return String(localized: "Recently Used") + case .alphabetical: return String(localized: "Alphabetical") + case .manual: return String(localized: "Manual") + } + } + + var iconName: String { + switch self { + case .recentlyUsed: return "clock" + case .alphabetical: return "textformat.abc" + case .manual: return "line.3.horizontal" + } + } + + var supportsInvert: Bool { + self != .manual + } + + static let allValues: [CategorySortMode] = CategorySortMode.allCases +} + +enum RecipeSortMode: String, CaseIterable { + case recentlyAdded = "recentlyAdded" + case alphabetical = "alphabetical" + + func descriptor() -> String { + switch self { + case .recentlyAdded: return String(localized: "Recently Added") + case .alphabetical: return String(localized: "Alphabetical") + } + } + + var iconName: String { + switch self { + case .recentlyAdded: return "clock" + case .alphabetical: return "textformat.abc" + } + } + + static let allValues: [RecipeSortMode] = RecipeSortMode.allCases +} diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index 657d2a2..a4a8cde 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -151,6 +151,30 @@ class UserSettings: ObservableObject { } } + @Published var categorySortMode: String { + didSet { + UserDefaults.standard.set(categorySortMode, forKey: "categorySortMode") + } + } + + @Published var categorySortAscending: Bool { + didSet { + UserDefaults.standard.set(categorySortAscending, forKey: "categorySortAscending") + } + } + + @Published var recipeSortMode: String { + didSet { + UserDefaults.standard.set(recipeSortMode, forKey: "recipeSortMode") + } + } + + @Published var recipeSortAscending: Bool { + didSet { + UserDefaults.standard.set(recipeSortAscending, forKey: "recipeSortAscending") + } + } + init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" @@ -175,6 +199,10 @@ class UserSettings: ObservableObject { self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true self.appearanceMode = UserDefaults.standard.object(forKey: "appearanceMode") as? String ?? AppearanceMode.system.rawValue + self.categorySortMode = UserDefaults.standard.object(forKey: "categorySortMode") as? String ?? CategorySortMode.recentlyUsed.rawValue + self.categorySortAscending = UserDefaults.standard.object(forKey: "categorySortAscending") as? Bool ?? true + self.recipeSortMode = UserDefaults.standard.object(forKey: "recipeSortMode") as? String ?? RecipeSortMode.recentlyAdded.rawValue + self.recipeSortAscending = UserDefaults.standard.object(forKey: "recipeSortAscending") as? Bool ?? true if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index ebe0c1f..ca15f21 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -692,6 +692,28 @@ } } }, + "Alphabetical" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alphabetisch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alfabético" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alphabétique" + } + } + } + }, "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { "extractionState" : "stale", "localizations" : { @@ -1403,6 +1425,28 @@ } } }, + "Default Order" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standardreihenfolge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orden predeterminado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordre par défaut" + } + } + } + }, "Delete" : { "localizations" : { "de" : { @@ -1768,6 +1812,28 @@ } } }, + "Edit Order" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reihenfolge bearbeiten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar orden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier l'ordre" + } + } + } + }, "Edit Recipe" : { "localizations" : { "de" : { @@ -2779,6 +2845,28 @@ } } }, + "Manual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel" + } + } + } + }, "Marked ingredients could not be adjusted!" : { "localizations" : { "de" : { @@ -3712,6 +3800,50 @@ } } }, + "Recently Added" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt hinzugefügt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadidos recientemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Récemment ajoutés" + } + } + } + }, + "Recently Used" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt verwendet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usados recientemente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Récemment utilisés" + } + } + } + }, "Recently Viewed" : { "localizations" : { "de" : { @@ -3978,6 +4110,50 @@ } } }, + "Reorder Categories" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kategorien sortieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reordenar categorías" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réorganiser les catégories" + } + } + } + }, + "Reverse Order" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umgekehrte Reihenfolge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orden inverso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordre inversé" + } + } + } + }, "Same as Device" : { "localizations" : { "de" : { diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 03ab322..3128546 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -79,6 +79,10 @@ struct MainView: View { // Load recently viewed recipes await appState.loadRecentRecipes() + // Load category sorting data + await appState.loadCategoryAccessDates() + await appState.loadManualCategoryOrder() + // Open detail view for default category if UserSettings.shared.defaultCategory != "" { if let cat = appState.categories.first(where: { c in diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift index af24122..68ecb49 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift @@ -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 + } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryReorderSheet.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryReorderSheet.swift new file mode 100644 index 0000000..f923d88 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryReorderSheet.swift @@ -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 + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 9e8cec0..f9bc897 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -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 + } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 63c3855..cbded96 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -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 {