// // 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 = [] 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 } }