The settings view was being popped immediately after push because multiple navigationDestination(isPresented:) modifiers on the same view caused SwiftUI to reset bindings when appState published changes. Replaced with a single navigationDestination(for: SidebarDestination.self) using an explicit NavigationStack(path:). Also fixed @ObservedObject -> @StateObject on SettingsView.ViewModel, added AllRecipesListView/AllRecipesCategoryCardView, and added translations for new strings. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
153 lines
5.1 KiB
Swift
153 lines
5.1 KiB
Swift
//
|
|
// AllRecipesCategoryCardView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AllRecipesCategoryCardView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var mosaicImages: [UIImage] = []
|
|
|
|
private var totalRecipeCount: Int {
|
|
appState.categories.reduce(0) { $0 + $1.recipe_count }
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
// 2x2 image mosaic or gradient fallback
|
|
if mosaicImages.count >= 4 {
|
|
mosaicGrid
|
|
} else {
|
|
gradientFallback
|
|
}
|
|
|
|
// Bottom scrim with text
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Spacer()
|
|
LinearGradient(
|
|
colors: [.clear, .black.opacity(0.95)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 60)
|
|
.overlay(alignment: .bottomLeading) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("All Recipes")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
Text("\(totalRecipeCount) recipes")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 140)
|
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
|
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
|
.task {
|
|
await loadMosaicImages()
|
|
}
|
|
}
|
|
|
|
private func loadMosaicImages() async {
|
|
// Ensure recipes are loaded for each category (they may not be yet)
|
|
for category in appState.categories {
|
|
if appState.recipes[category.name] == nil || appState.recipes[category.name]!.isEmpty {
|
|
await appState.getCategory(named: category.name, fetchMode: .preferLocal)
|
|
}
|
|
}
|
|
|
|
// Collect all recipes across categories, shuffled for variety
|
|
var allRecipes: [Recipe] = []
|
|
for category in appState.categories {
|
|
if let recipes = appState.recipes[category.name] {
|
|
allRecipes.append(contentsOf: recipes)
|
|
}
|
|
}
|
|
allRecipes.shuffle()
|
|
|
|
// Filter to recipes that have an image URL, then pick 4
|
|
var candidates: [Recipe] = []
|
|
var seenIds: Set<Int> = []
|
|
for recipe in allRecipes {
|
|
guard let url = recipe.imageUrl, !url.isEmpty else { continue }
|
|
guard !seenIds.contains(recipe.recipe_id) else { continue }
|
|
seenIds.insert(recipe.recipe_id)
|
|
candidates.append(recipe)
|
|
if candidates.count >= 4 { break }
|
|
}
|
|
|
|
var images: [UIImage] = []
|
|
for recipe in candidates {
|
|
if let image = await appState.getImage(
|
|
id: recipe.recipe_id,
|
|
size: .THUMB,
|
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
|
) {
|
|
images.append(image)
|
|
}
|
|
}
|
|
guard !images.isEmpty else { return }
|
|
// Cycle to fill 4 slots if fewer than 4 unique images
|
|
var filled: [UIImage] = []
|
|
for i in 0..<4 {
|
|
filled.append(images[i % images.count])
|
|
}
|
|
mosaicImages = filled
|
|
}
|
|
|
|
private var mosaicGrid: some View {
|
|
VStack(spacing: 1) {
|
|
HStack(spacing: 1) {
|
|
imageCell(mosaicImages[safe: 0])
|
|
imageCell(mosaicImages[safe: 1])
|
|
}
|
|
HStack(spacing: 1) {
|
|
imageCell(mosaicImages[safe: 2])
|
|
imageCell(mosaicImages[safe: 3])
|
|
}
|
|
}
|
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
|
.clipped()
|
|
}
|
|
|
|
private func imageCell(_ image: UIImage?) -> some View {
|
|
Group {
|
|
if let image {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
|
.clipped()
|
|
} else {
|
|
Color.gray
|
|
}
|
|
}
|
|
}
|
|
|
|
private var gradientFallback: some View {
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
|
.overlay(alignment: .center) {
|
|
Image(systemName: "square.grid.2x2.fill")
|
|
.font(.system(size: 36))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|