feat(wallet): NFT thumbnails and metadata display in Assets tab

- Add NFT metadata fetching via Koios asset_info endpoint
- Parse CIP-25 onchain_metadata for image, name, description
- Convert IPFS URLs to ipfs.io gateway URLs
- Display 64dp thumbnails with 8dp rounded corners using Coil AsyncImage
- Add bottom sheet detail view for NFT expansion (larger image + metadata)
- Graceful fallback with placeholder icons on image load failure
- Load metadata in presenter, cache results for 30 minutes
- Parallel metadata fetching for better performance
This commit is contained in:
Kayos 2026-03-29 15:21:53 -07:00
parent a57fd79098
commit 2d8df4f23f
8 changed files with 572 additions and 11 deletions

View file

@ -81,4 +81,15 @@ interface CardanoClient {
* @return Bech32 Cardano address if handle exists, null if not found
*/
suspend fun resolveHandle(handle: String): Result<String?>
/**
* Get CIP-25 NFT metadata for a specific asset.
*
* Uses the Koios asset_info endpoint to fetch onchain_metadata.
*
* @param policyId The minting policy ID (hex, 56 chars)
* @param assetName The asset name (hex encoded)
* @return [NftMetadata] if CIP-25 metadata exists, null otherwise
*/
suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?>
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.api
/**
* CIP-25 NFT metadata parsed from Koios asset_info response.
*
* @property name The NFT name
* @property image Resolved HTTP URL for the image (IPFS gateway or direct HTTPS)
* @property description NFT description if available
* @property rawMetadata Original metadata map for additional fields
*/
data class NftMetadata(
val name: String,
val image: String?,
val description: String?,
val rawMetadata: Map<String, Any>,
) {
companion object {
private const val IPFS_GATEWAY = "https://ipfs.io/ipfs/"
/**
* Resolve IPFS URLs to HTTP gateway URLs.
*/
fun resolveImageUrl(url: String?): String? {
if (url == null) return null
return when {
url.startsWith("ipfs://") -> IPFS_GATEWAY + url.removePrefix("ipfs://")
url.startsWith("Qm") -> IPFS_GATEWAY + url // Direct IPFS hash
url.startsWith("https://") || url.startsWith("http://") -> url
else -> null
}
}
/**
* Join array-based image URL (some NFTs split the URL across multiple strings).
*/
fun joinImageParts(parts: List<String>?): String? {
if (parts.isNullOrEmpty()) return null
return resolveImageUrl(parts.joinToString(""))
}
}
}