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:
@@ -80,6 +80,8 @@
|
|||||||
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; };
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; };
|
||||||
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; };
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; };
|
||||||
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -177,6 +179,8 @@
|
|||||||
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
|
||||||
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
|
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
|
||||||
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
|
||||||
|
G1A0CE002F0B000100000001 /* CategorySortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySortMode.swift; sourceTree = "<group>"; };
|
||||||
|
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryReorderSheet.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -323,6 +327,7 @@
|
|||||||
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
|
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
|
||||||
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
|
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
|
||||||
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
|
||||||
|
G1A0CE002F0B000100000001 /* CategorySortMode.swift */,
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -433,6 +438,7 @@
|
|||||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||||
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
||||||
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
|
||||||
|
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */,
|
||||||
);
|
);
|
||||||
path = Recipes;
|
path = Recipes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -670,6 +676,8 @@
|
|||||||
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */,
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */,
|
||||||
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */,
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */,
|
||||||
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */,
|
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */,
|
||||||
|
G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */,
|
||||||
|
G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import UIKit
|
|||||||
@Published var timers: [String: RecipeTimer] = [:]
|
@Published var timers: [String: RecipeTimer] = [:]
|
||||||
@Published var categoryImages: [String: UIImage] = [:]
|
@Published var categoryImages: [String: UIImage] = [:]
|
||||||
@Published var recentRecipes: [Recipe] = []
|
@Published var recentRecipes: [Recipe] = []
|
||||||
|
@Published var categoryAccessDates: [String: Date] = [:]
|
||||||
|
@Published var manualCategoryOrder: [String] = []
|
||||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||||
var lastUpdates: [String: Date] = [:]
|
var lastUpdates: [String: Date] = [:]
|
||||||
@@ -347,6 +349,34 @@ import UIKit
|
|||||||
dataStore.delete(path: "recent_recipes.data")
|
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
|
// MARK: - Data management
|
||||||
|
|
||||||
func deleteAllData() {
|
func deleteAllData() {
|
||||||
|
|||||||
55
Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift
Normal file
55
Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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() {
|
init() {
|
||||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||||
self.token = UserDefaults.standard.object(forKey: "token") 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.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
|
||||||
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") 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.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 authString == "" {
|
||||||
if token != "" && username != "" {
|
if token != "" && username != "" {
|
||||||
|
|||||||
@@ -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." : {
|
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"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" : {
|
"Delete" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"Edit Recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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!" : {
|
"Marked ingredients could not be adjusted!" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"Recently Viewed" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"Same as Device" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ struct MainView: View {
|
|||||||
// Load recently viewed recipes
|
// Load recently viewed recipes
|
||||||
await appState.loadRecentRecipes()
|
await appState.loadRecentRecipes()
|
||||||
|
|
||||||
|
// Load category sorting data
|
||||||
|
await appState.loadCategoryAccessDates()
|
||||||
|
await appState.loadManualCategoryOrder()
|
||||||
|
|
||||||
// Open detail view for default category
|
// Open detail view for default category
|
||||||
if UserSettings.shared.defaultCategory != "" {
|
if UserSettings.shared.defaultCategory != "" {
|
||||||
if let cat = appState.categories.first(where: { c in
|
if let cat = appState.categories.first(where: { c in
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct AllRecipesListView: View {
|
|||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
var onCreateNew: () -> Void
|
var onCreateNew: () -> Void
|
||||||
var onImportFromURL: () -> Void
|
var onImportFromURL: () -> Void
|
||||||
@State private var allRecipes: [Recipe] = []
|
@State private var allRecipes: [Recipe] = []
|
||||||
@@ -16,6 +17,10 @@ struct AllRecipesListView: View {
|
|||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private var currentRecipeSortMode: RecipeSortMode {
|
||||||
|
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
let recipes = recipesFiltered()
|
let recipes = recipesFiltered()
|
||||||
@@ -69,6 +74,9 @@ struct AllRecipesListView: View {
|
|||||||
.environmentObject(mealPlan)
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
recipeSortMenu
|
||||||
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
@@ -94,11 +102,83 @@ struct AllRecipesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recipesFiltered() -> [Recipe] {
|
private var recipeSortMenu: some View {
|
||||||
guard !searchText.isEmpty else { return allRecipes }
|
Menu {
|
||||||
return allRecipes.filter { recipe in
|
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
Button {
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ struct RecipeListView: View {
|
|||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
var onCreateNew: () -> Void
|
var onCreateNew: () -> Void
|
||||||
@@ -22,6 +23,10 @@ struct RecipeListView: View {
|
|||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private var currentRecipeSortMode: RecipeSortMode {
|
||||||
|
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
let recipes = recipesFiltered()
|
let recipes = recipesFiltered()
|
||||||
@@ -82,6 +87,9 @@ struct RecipeListView: View {
|
|||||||
.environmentObject(mealPlan)
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
recipeSortMenu
|
||||||
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
@@ -113,12 +121,84 @@ struct RecipeListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recipesFiltered() -> [Recipe] {
|
private var recipeSortMenu: some View {
|
||||||
guard let recipes = appState.recipes[categoryName] else { return [] }
|
Menu {
|
||||||
guard searchText != "" else { return recipes }
|
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
||||||
return recipes.filter { recipe in
|
Button {
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
userSettings.recipeSortMode = mode.rawValue
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,103 @@ struct RecipeTabView: View {
|
|||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
@State private var showManualReorderSheet = false
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
private var nonEmptyCategories: [Category] {
|
private static let allRecipesSentinel = "__ALL_RECIPES__"
|
||||||
appState.categories.filter { $0.recipe_count > 0 }
|
|
||||||
|
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 {
|
private var sortedCategoryNames: [String] {
|
||||||
appState.categories.reduce(0) { $0 + $1.recipe_count }
|
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 {
|
var body: some View {
|
||||||
@@ -37,16 +124,20 @@ struct RecipeTabView: View {
|
|||||||
RecentRecipesSection()
|
RecentRecipesSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories header
|
// Categories header with sort button
|
||||||
if !nonEmptyCategories.isEmpty {
|
if hasCategories {
|
||||||
Text("Categories")
|
HStack {
|
||||||
.font(.title2)
|
Text("Categories")
|
||||||
.bold()
|
.font(.title2)
|
||||||
.padding(.horizontal)
|
.bold()
|
||||||
|
Spacer()
|
||||||
|
categorySortMenu
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category grid
|
// Category grid
|
||||||
if nonEmptyCategories.isEmpty {
|
if !hasCategories {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "book.closed")
|
Image(systemName: "book.closed")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
@@ -63,31 +154,32 @@ struct RecipeTabView: View {
|
|||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
// All Recipes card
|
ForEach(sortedCategoryNames, id: \.self) { name in
|
||||||
if totalRecipeCount > 0 {
|
if name == Self.allRecipesSentinel {
|
||||||
Button {
|
Button {
|
||||||
viewModel.navigateToAllRecipes()
|
appState.trackCategoryAccess(Self.allRecipesSentinel)
|
||||||
} label: {
|
viewModel.navigateToAllRecipes()
|
||||||
AllRecipesCategoryCardView()
|
} label: {
|
||||||
}
|
AllRecipesCategoryCardView()
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(nonEmptyCategories) { category in
|
|
||||||
Button {
|
|
||||||
if horizontalSizeClass == .compact {
|
|
||||||
viewModel.navigateToCategory(category)
|
|
||||||
} else {
|
|
||||||
viewModel.selectedCategory = category
|
|
||||||
viewModel.showAllRecipesInDetail = false
|
|
||||||
}
|
}
|
||||||
} label: {
|
.buttonStyle(.plain)
|
||||||
CategoryCardView(
|
} else if let category = appState.categories.first(where: { $0.name == name && $0.recipe_count > 0 }) {
|
||||||
category: category,
|
Button {
|
||||||
isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name
|
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)
|
.padding(.horizontal)
|
||||||
@@ -169,6 +261,10 @@ struct RecipeTabView: View {
|
|||||||
}
|
}
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showManualReorderSheet) {
|
||||||
|
CategoryReorderSheet()
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
let connection = await appState.checkServerConnection()
|
let connection = await appState.checkServerConnection()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -240,7 +336,7 @@ struct RecipeTabView: View {
|
|||||||
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
var body: some ToolbarContent {
|
||||||
// Top left menu toolbar item
|
// Top left menu toolbar item
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
@@ -260,7 +356,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
Text("Refresh all")
|
Text("Refresh all")
|
||||||
Image(systemName: "icloud.and.arrow.down")
|
Image(systemName: "icloud.and.arrow.down")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.navigateToSettings()
|
viewModel.navigateToSettings()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -271,7 +367,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
Image(systemName: "ellipsis.circle")
|
Image(systemName: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server connection indicator
|
// Server connection indicator
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
Reference in New Issue
Block a user