Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesCategoryCardView.swift
Hendrik Hogertz 151e69ff28 Avoid reusing category thumbnail images in all recipes preview mosaic
Track which recipe IDs are used as category thumbnails and prefer
different images for the all recipes 2x2 mosaic, falling back to
category thumbnails only when not enough other images are available.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 07:52:18 +01:00

167 lines
5.6 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
// Prefer recipes not already used as category thumbnails
let categoryImageIds = appState.categoryImageRecipeIds
var candidates: [Recipe] = []
var fallbackCandidates: [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)
if categoryImageIds.contains(recipe.recipe_id) {
fallbackCandidates.append(recipe)
} else {
candidates.append(recipe)
}
if candidates.count >= 4 { break }
}
// Fill remaining slots from fallback if needed
if candidates.count < 4 {
for recipe in fallbackCandidates {
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
}
}