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:
parent
a57fd79098
commit
2d8df4f23f
8 changed files with 572 additions and 11 deletions
|
|
@ -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?>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,10 @@ dependencies {
|
|||
// Coroutines
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
// Image loading for NFT thumbnails
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.features.wallet.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import dev.zacsweers.metro.Inject
|
|||
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
|
||||
|
|
@ -73,6 +74,9 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
private data class CachedHandle(val address: String?, val timestamp: Long)
|
||||
private val handleCache = mutableMapOf<String, CachedHandle>()
|
||||
|
||||
// NFT metadata cache
|
||||
private val nftMetadataCache = mutableMapOf<String, NftMetadata?>()
|
||||
|
||||
override suspend fun getBalance(address: String): Result<Long> =
|
||||
withRetry("getBalance($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -306,12 +310,14 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
val assets = assetMap.map { (key, quantity) ->
|
||||
val policyId = key.take(56)
|
||||
val assetNameHex = key.drop(56)
|
||||
// Mark as potential NFT if quantity is 1
|
||||
NativeAsset(
|
||||
policyId = policyId,
|
||||
assetName = assetNameHex,
|
||||
quantity = quantity,
|
||||
displayName = null,
|
||||
fingerprint = null,
|
||||
isNft = quantity == 1L,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +420,152 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?> =
|
||||
withRetry("getNftMetadata($policyId, $assetName)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheKey = "$policyId$assetName"
|
||||
|
||||
// Check cache first
|
||||
if (nftMetadataCache.containsKey(cacheKey)) {
|
||||
return@withContext Result.success(nftMetadataCache[cacheKey])
|
||||
}
|
||||
|
||||
throttleRequest()
|
||||
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_asset_list", JSONArray().put(JSONArray().apply {
|
||||
put(policyId)
|
||||
put(assetName)
|
||||
}))
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("getNftMetadata calling: $url with policy=$policyId, asset=$assetName")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
Timber.tag(TAG).d("getNftMetadata response: code=${response.code}, body=${responseBody.take(1000)}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
nftMetadataCache[cacheKey] = null
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
|
||||
val assetInfo = jsonArray.getJSONObject(0)
|
||||
|
||||
// Parse CIP-25 onchain_metadata
|
||||
val metadata = try {
|
||||
parseCip25Metadata(assetInfo)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Failed to parse CIP-25 metadata")
|
||||
null
|
||||
}
|
||||
|
||||
nftMetadataCache[cacheKey] = metadata
|
||||
Result.success(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CIP-25 metadata from Koios asset_info response.
|
||||
*/
|
||||
private fun parseCip25Metadata(assetInfo: JSONObject): NftMetadata? {
|
||||
// Check for onchain_metadata (CIP-25)
|
||||
val onchainMetadata = assetInfo.optJSONObject("onchain_metadata") ?: return null
|
||||
|
||||
// Get asset name for lookup (decoded)
|
||||
val assetNameHex = assetInfo.optString("asset_name", "")
|
||||
val assetNameDecoded = assetInfo.optString("asset_name_ascii", "")
|
||||
|
||||
// Extract name - could be in various places
|
||||
val name = onchainMetadata.optString("name")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: assetNameDecoded.takeIf { it.isNotEmpty() }
|
||||
?: assetNameHex
|
||||
|
||||
// Extract image - handle both string and array formats
|
||||
val imageUrl = extractImageUrl(onchainMetadata)
|
||||
|
||||
// Extract description
|
||||
val description = onchainMetadata.optString("description")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Build raw metadata map
|
||||
val rawMetadata = mutableMapOf<String, Any>()
|
||||
onchainMetadata.keys().forEach { key ->
|
||||
val value = onchainMetadata.get(key)
|
||||
if (value != null && value != JSONObject.NULL) {
|
||||
rawMetadata[key] = convertJsonValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
return NftMetadata(
|
||||
name = name,
|
||||
image = imageUrl,
|
||||
description = description,
|
||||
rawMetadata = rawMetadata,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image URL from CIP-25 metadata, handling various formats.
|
||||
*/
|
||||
private fun extractImageUrl(metadata: JSONObject): String? {
|
||||
return try {
|
||||
when (val imageValue = metadata.opt("image")) {
|
||||
is String -> NftMetadata.resolveImageUrl(imageValue)
|
||||
is JSONArray -> {
|
||||
// Some NFTs split the URL across multiple array elements
|
||||
val parts = (0 until imageValue.length()).mapNotNull {
|
||||
imageValue.optString(it).takeIf { s -> s.isNotEmpty() }
|
||||
}
|
||||
NftMetadata.joinImageParts(parts)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Failed to extract image URL")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JSON value to Kotlin type for raw metadata map.
|
||||
*/
|
||||
private fun convertJsonValue(value: Any): Any {
|
||||
return when (value) {
|
||||
is JSONObject -> {
|
||||
val map = mutableMapOf<String, Any>()
|
||||
value.keys().forEach { key ->
|
||||
val v = value.get(key)
|
||||
if (v != null && v != JSONObject.NULL) {
|
||||
map[key] = convertJsonValue(v)
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
is JSONArray -> {
|
||||
(0 until value.length()).mapNotNull { i ->
|
||||
val v = value.opt(i)
|
||||
if (v != null && v != JSONObject.NULL) convertJsonValue(v) else null
|
||||
}
|
||||
}
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> withRetry(
|
||||
operation: String,
|
||||
block: suspend () -> Result<T>,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import dev.zacsweers.metro.Assisted
|
|||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
|
|||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.CardanoClient
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.api.NftMetadata
|
||||
import io.element.android.features.wallet.api.TxSummary
|
||||
import io.element.android.features.wallet.api.backup.WalletBackupService
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
|
|
@ -24,6 +25,9 @@ import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
|
|||
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -83,9 +87,11 @@ class WalletPanelPresenter @Inject constructor(
|
|||
walletManager.refreshBalance(matrixClient.sessionId, balance)
|
||||
}
|
||||
|
||||
// Fetch assets
|
||||
// Fetch assets and enrich with NFT metadata
|
||||
cardanoClient.getAddressAssets(address)
|
||||
.onSuccess { assets = it }
|
||||
.onSuccess { fetchedAssets ->
|
||||
assets = enrichAssetsWithMetadata(fetchedAssets)
|
||||
}
|
||||
.onFailure { Timber.w(it, "Failed to fetch assets") }
|
||||
|
||||
// Fetch transactions
|
||||
|
|
@ -280,4 +286,59 @@ class WalletPanelPresenter @Inject constructor(
|
|||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich assets with NFT metadata from Koios.
|
||||
* Fetches CIP-25 metadata for potential NFTs (quantity == 1) in parallel.
|
||||
*/
|
||||
private suspend fun enrichAssetsWithMetadata(assets: List<NativeAsset>): List<NativeAsset> {
|
||||
if (assets.isEmpty()) return assets
|
||||
|
||||
// Identify potential NFTs (quantity == 1 or marked as NFT)
|
||||
val potentialNfts = assets.filter { it.quantity == 1L || it.isNft }
|
||||
if (potentialNfts.isEmpty()) return assets
|
||||
|
||||
Timber.d("Enriching ${potentialNfts.size} potential NFTs with metadata")
|
||||
|
||||
// Fetch metadata in parallel (max 10 concurrent to avoid rate limiting)
|
||||
val metadataMap = mutableMapOf<String, NftMetadata>()
|
||||
try {
|
||||
coroutineScope {
|
||||
potentialNfts.chunked(10).forEach { chunk ->
|
||||
chunk.map { asset ->
|
||||
async {
|
||||
cardanoClient.getNftMetadata(asset.policyId, asset.assetName)
|
||||
.onSuccess { metadata ->
|
||||
if (metadata != null) {
|
||||
metadataMap[asset.unit] = metadata
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.w(e, "Failed to fetch metadata for ${asset.unit}")
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error during metadata enrichment, continuing without full metadata")
|
||||
}
|
||||
|
||||
Timber.d("Successfully fetched metadata for ${metadataMap.size} NFTs")
|
||||
|
||||
// Apply metadata to assets
|
||||
return assets.map { asset ->
|
||||
val metadata = metadataMap[asset.unit]
|
||||
if (metadata != null) {
|
||||
asset.copy(
|
||||
displayName = metadata.name.takeIf { it.isNotEmpty() } ?: asset.displayName,
|
||||
imageUrl = metadata.image,
|
||||
description = metadata.description,
|
||||
isNft = metadata.image != null,
|
||||
)
|
||||
} else {
|
||||
asset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,44 @@
|
|||
|
||||
package io.element.android.features.wallet.impl.panel.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.impl.R
|
||||
|
|
@ -38,6 +57,8 @@ fun AssetsTabView(
|
|||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selectedNft by remember { mutableStateOf<NativeAsset?>(null) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
isLoading -> {
|
||||
|
|
@ -73,29 +94,77 @@ fun AssetsTabView(
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(assets) { asset ->
|
||||
AssetCard(asset = asset)
|
||||
AssetCard(
|
||||
asset = asset,
|
||||
onClick = { if (asset.isNft || asset.imageUrl != null) selectedNft = asset },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NFT Detail Bottom Sheet
|
||||
selectedNft?.let { nft ->
|
||||
NftDetailBottomSheet(
|
||||
asset = nft,
|
||||
onDismiss = { selectedNft = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssetCard(
|
||||
asset: NativeAsset,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasImage = asset.imageUrl != null
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (hasImage) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// NFT Thumbnail (64dp square, 8dp rounded corners)
|
||||
if (hasImage) {
|
||||
NftThumbnail(
|
||||
imageUrl = asset.imageUrl!!,
|
||||
contentDescription = asset.name,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
} else {
|
||||
// Placeholder for non-NFT tokens
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Info(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Asset info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
|
|
@ -104,21 +173,208 @@ private fun AssetCard(
|
|||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = asset.truncatedPolicyId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (asset.isNft) {
|
||||
Text(
|
||||
text = "NFT",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity
|
||||
Text(
|
||||
text = asset.quantity.toString(),
|
||||
text = asset.formatQuantity(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NftThumbnail(
|
||||
imageUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
onState = { state ->
|
||||
isLoading = state is AsyncImagePainter.State.Loading
|
||||
isError = state is AsyncImagePainter.State.Error
|
||||
},
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// Error placeholder
|
||||
if (isError) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Error(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NftDetailBottomSheet(
|
||||
asset: NativeAsset,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Large NFT image
|
||||
asset.imageUrl?.let { url ->
|
||||
NftDetailImage(
|
||||
imageUrl = url,
|
||||
contentDescription = asset.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// NFT Name
|
||||
Text(
|
||||
text = asset.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Policy ID
|
||||
Text(
|
||||
text = asset.truncatedPolicyId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Description if available
|
||||
asset.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Quantity badge
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Quantity: ${asset.formatQuantity()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NftDetailImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
onState = { state ->
|
||||
isLoading = state is AsyncImagePainter.State.Loading
|
||||
isError = state is AsyncImagePainter.State.Error
|
||||
},
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
// Error placeholder
|
||||
if (isError) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Error(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Failed to load image",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AssetsTabViewPreview() = ElementPreview {
|
||||
|
|
@ -133,10 +389,13 @@ internal fun AssetsTabViewPreview() = ElementPreview {
|
|||
),
|
||||
NativeAsset(
|
||||
policyId = "11223344556677889900aabbccdd11223344556677889900aabbccdd",
|
||||
assetName = "",
|
||||
quantity = 5,
|
||||
displayName = null,
|
||||
assetName = "436f6f6c4e4654",
|
||||
quantity = 1,
|
||||
displayName = "CoolNFT",
|
||||
fingerprint = null,
|
||||
imageUrl = "https://ipfs.io/ipfs/QmTest123",
|
||||
isNft = true,
|
||||
description = "A really cool NFT from the Cardano blockchain",
|
||||
),
|
||||
),
|
||||
isLoading = false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue