// // HomePosterCell.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Adwaita import Foundation import LuminateCore import LuminateDI struct HomePosterCell: View { let itemId: String? let title: String? let subtitle: String? let width: Int let imageAspectRatio: Float let onClick: (() -> Void)? @Injected(\.client) private var client @Injected(\.imageService) private var imageService @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler @State private var imageData: Data? private struct Constants { static let padding = 8 } init( itemId: String? = nil, title: String? = nil, subtitle: String? = nil, width: Int = 200, imageAspectRatio: Float = 1.5, onClick: @escaping () -> Void ) { self.itemId = itemId self.title = title self.subtitle = subtitle self.width = width self.imageAspectRatio = imageAspectRatio self.onClick = onClick } var view: Body { Bin { VStack(spacing: showTextSection ? 6 : 0) { imageSection if showTextSection { textSection } } .padding(padding) } .onClick(handler: onClick ?? {}) .onAppear { Idle { loadImage() } } .frame(minWidth: width) .halign(.fill) .valign(.start) .hexpand(false) .style("activatable") .card() } @ViewBuilder private var imageSection: Body { AspectContainer(aspectRatio: imageAspectRatio) .child { image .halign(.fill) .hexpand() .overflow(.hidden) .card() } .frame(minWidth: width - (padding * 2)) } @ViewBuilder private var image: Body { if let imageData { Picture() .contentFit(.cover) .data(imageData) .valign(.fill) .halign(.fill) .vexpand() .hexpand() .transition(.crossfade) } else { Spinner() .frame(minWidth: 64, minHeight: 64) .transition(.crossfade) } } @ViewBuilder private var textSection: Body { Bin { VStack(spacing: 2) { if let title, !title.isEmpty { Text(title) .maxWidthChars(0) .ellipsize() .heading() } if let subtitle, !subtitle.isEmpty { Text(subtitle) .maxWidthChars(0) .ellipsize() .caption() .dimLabel() } } .valign(.center) } .frame(minHeight: 40) .hexpand() } private var showTextSection: Bool { (title?.isEmpty == false) && (subtitle?.isEmpty == false) } private var padding: Int { showTextSection ? Constants.padding : 0 } private func loadImage() { guard let itemId else { return } guard let url = client.imageURL( itemId: itemId, imageType: .primary, maxWidth: width.cInt * 2 ) else { return } Task { let data = try? await imageService.loadImage(url: url) _imageData.rawValue = data viewUpdateScheduler.scheduleFlush() } } }