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

@ -9,6 +9,7 @@ package io.element.android.features.wallet.test
import io.element.android.features.wallet.api.CardanoClient
import io.element.android.features.wallet.api.CardanoException
import io.element.android.features.wallet.api.NativeAsset
import io.element.android.features.wallet.api.NftMetadata
import io.element.android.features.wallet.api.ProtocolParameters
import io.element.android.features.wallet.api.TxStatus
import io.element.android.features.wallet.api.TxSummary
@ -24,6 +25,7 @@ import io.element.android.features.wallet.api.UtxoAsset
* - Rate limiting
* - Transaction lifecycle (pending confirmed)
* - ADA Handle resolution
* - NFT metadata
*/
class FakeCardanoClient : CardanoClient {
// Configurable responses
@ -34,6 +36,7 @@ class FakeCardanoClient : CardanoClient {
var assets = mutableMapOf<String, List<NativeAsset>>()
var transactions = mutableMapOf<String, List<TxSummary>>()
var handles = mutableMapOf<String, String>() // handle name (without $) -> address
var nftMetadata = mutableMapOf<String, NftMetadata>() // policyId+assetName -> metadata
// Error simulation
var shouldFailWithNetworkError = false
@ -67,6 +70,8 @@ class FakeCardanoClient : CardanoClient {
private set
var resolveHandleCallCount = 0
private set
var getNftMetadataCallCount = 0
private set
/**
* Represents a submitted transaction for testing.
@ -204,6 +209,20 @@ class FakeCardanoClient : CardanoClient {
return Result.success(address)
}
override suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?> {
getNftMetadataCallCount++
if (shouldFailWithNetworkError) {
return Result.failure(CardanoException.NetworkException("Simulated network error"))
}
if (shouldFailWithRateLimit) {
return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L))
}
val key = "$policyId$assetName"
return Result.success(nftMetadata[key])
}
// Helper methods for test setup
/**
@ -270,6 +289,13 @@ class FakeCardanoClient : CardanoClient {
handles[handle.lowercase().trim()] = address
}
/**
* Configures NFT metadata for an asset.
*/
fun givenNftMetadata(policyId: String, assetName: String, metadata: NftMetadata) {
nftMetadata["$policyId$assetName"] = metadata
}
/**
* Resets all state and counters.
*/
@ -281,6 +307,7 @@ class FakeCardanoClient : CardanoClient {
assets.clear()
transactions.clear()
handles.clear()
nftMetadata.clear()
shouldFailWithNetworkError = false
shouldFailWithRateLimit = false
submitShouldFail = false
@ -294,6 +321,7 @@ class FakeCardanoClient : CardanoClient {
getAddressAssetsCallCount = 0
getAddressTransactionsCallCount = 0
resolveHandleCallCount = 0
getNftMetadataCallCount = 0
protocolParameters = ProtocolParameters(
minFeeA = 44L,
minFeeB = 155381L,