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>
167 lines
5.6 KiB
Swift
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
|
|
}
|
|
}
|