diff --git a/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt b/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt index 68d86230a..f561a6070 100644 --- a/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt +++ b/android/app-newm/src/main/java/io/newm/di/android/Dependencies.kt @@ -120,7 +120,9 @@ val viewModule = ) } - factory { params -> WalletsPresenter(params.get(), get(), get(), get(), get(), get(), get()) } + factory { params -> + WalletsPresenter(params.get(), get(), get(), get(), get(), get(), get(), get()) + } factory { params -> WalletDetailPresenter( @@ -132,8 +134,6 @@ val viewModule = syncWalletConnectionsUseCase = get(), logger = get(), nftTracksUseCase = get(), - getPortfolioDataUseCase = get(), - recaptchaClientProvider = get(), ) } diff --git a/android/app-newm/src/main/java/io/newm/screens/investment/portfolio/InvestmentPortfolioPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/investment/portfolio/InvestmentPortfolioPresenter.kt index 889e1cb95..09af5d52c 100644 --- a/android/app-newm/src/main/java/io/newm/screens/investment/portfolio/InvestmentPortfolioPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/investment/portfolio/InvestmentPortfolioPresenter.kt @@ -40,7 +40,7 @@ class InvestmentPortfolioPresenter( .onSuccess { token -> value = getPortfolioDataUseCase.getInvestmentPortfolio( - walletAddress = wallets.first().stakeAddress, + walletAddress = wallets.first().address, humanVerificationCode = token, ) }.onFailure { diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt index aab7867c9..f9b41079f 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryPresenter.kt @@ -22,6 +22,7 @@ import io.newm.shared.commonPublic.analytics.NewmAppEventLogger import io.newm.shared.commonPublic.analytics.events.AppScreens import io.newm.shared.commonPublic.featureflags.FeatureFlagService import io.newm.shared.commonPublic.featureflags.FeatureFlags +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.NFTTrack import io.newm.shared.commonPublic.usecases.ConnectWalletUseCase import io.newm.shared.commonPublic.usecases.HasWalletConnectionsUseCase @@ -88,19 +89,28 @@ class NFTLibraryPresenter( var filters: NFTLibraryFilters by rememberRetained { mutableStateOf( - NFTLibraryFilters(sortType = NFTLibrarySortType.None, showShortTracks = false), + NFTLibraryFilters(sortType = NFTLibrarySortType.None, showShortTracks = true), ) } - val filteredNftTracks = - remember(nftTracks, query, filters) { nftTracks.filterAndSort(query, filters) } + val (filteredEthereumTracks, filteredCardanoTracks) = + remember(nftTracks, query, filters) { + val filtered = nftTracks.filterAndSort(query, filters) + val ethereum = filtered.filter { it.chainType == ChainType.Ethereum } + val cardano = filtered.filter { it.chainType == ChainType.Cardano } + ethereum to cardano + } val filteredStreamTokens = remember(streamTracks, query, filters) { streamTracks.filterAndSort(query, filters) } val playList = - remember(filteredNftTracks, filteredStreamTokens) { - Playlist(filteredNftTracks.toTrack() + filteredStreamTokens.toTrack()) + remember(filteredEthereumTracks, filteredCardanoTracks, filteredStreamTokens) { + Playlist( + filteredEthereumTracks.toTrack() + + filteredCardanoTracks.toTrack() + + filteredStreamTokens.toTrack(), + ) } val currentTrackId = @@ -182,7 +192,8 @@ class NFTLibraryPresenter( else -> { NFTLibraryState.Content( - nftTracks = filteredNftTracks, + ethereumTracks = filteredEthereumTracks, + cardanoTracks = filteredCardanoTracks, streamTokenTracks = filteredStreamTokens, showZeroResultFound = showZeroResultFound, filters = filters, diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt index a2caf5094..f29705eb9 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryScreenUi.kt @@ -71,6 +71,8 @@ import io.newm.core.resources.library_remove_description import io.newm.core.resources.library_search import io.newm.core.resources.nft_library_error_message import io.newm.core.resources.title_nft_library +import io.newm.core.resources.wallet_detail_cardano_header +import io.newm.core.resources.wallet_detail_ethereum_header import io.newm.core.ui.LoadingScreen import io.newm.core.ui.text.SearchBar import io.newm.core.ui.theme.CerisePink @@ -161,7 +163,8 @@ fun NFTLibraryScreenUi( NFTTracks( modifier = Modifier.weight(1f), - nftTracks = state.nftTracks, + ethereumTracks = state.ethereumTracks, + cardanoTracks = state.cardanoTracks, streamTokenTracks = state.streamTokenTracks, showZeroResultsFound = state.showZeroResultFound, filters = state.filters, @@ -186,7 +189,8 @@ fun NFTLibraryScreenUi( @Composable private fun NFTTracks( modifier: Modifier = Modifier, - nftTracks: List, + ethereumTracks: List, + cardanoTracks: List, streamTokenTracks: List, showZeroResultsFound: Boolean, filters: NFTLibraryFilters, @@ -239,19 +243,63 @@ private fun NFTTracks( item { ZeroSearchResults() } } - nftTracks.isNotEmpty() || streamTokenTracks.isNotEmpty() -> { - items(nftTracks + streamTokenTracks, key = { track -> track.id }) { track -> - Box(modifier = Modifier.background(Gray16)) { - TrackRowItemWrapper( - track = track, - onPlaySong = onPlaySong, - onDownloadSong = { onDownloadSong(track) }, - isSelected = track.id == currentTrackId, - downloadsEnabled = downloadsEnabled, - downloadState = downloadStates[track.id] ?: DownloadState.None, - onRemoveSong = { onRemoveSong(track) }, + ethereumTracks.isNotEmpty() || + cardanoTracks.isNotEmpty() || + streamTokenTracks.isNotEmpty() -> { + if (ethereumTracks.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(Res.string.wallet_detail_ethereum_header), ) } + items(ethereumTracks, key = { track -> track.id }) { track -> + Box(modifier = Modifier.background(Gray16)) { + TrackRowItemWrapper( + track = track, + onPlaySong = onPlaySong, + onDownloadSong = { onDownloadSong(track) }, + isSelected = track.id == currentTrackId, + downloadsEnabled = downloadsEnabled, + downloadState = downloadStates[track.id] ?: DownloadState.None, + onRemoveSong = { onRemoveSong(track) }, + ) + } + } + } + if (cardanoTracks.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(Res.string.wallet_detail_cardano_header), + ) + } + items(cardanoTracks, key = { track -> track.id }) { track -> + Box(modifier = Modifier.background(Gray16)) { + TrackRowItemWrapper( + track = track, + onPlaySong = onPlaySong, + onDownloadSong = { onDownloadSong(track) }, + isSelected = track.id == currentTrackId, + downloadsEnabled = downloadsEnabled, + downloadState = downloadStates[track.id] ?: DownloadState.None, + onRemoveSong = { onRemoveSong(track) }, + ) + } + } + } + if (streamTokenTracks.isNotEmpty()) { + items(streamTokenTracks, key = { track -> track.id }) { track -> + Box(modifier = Modifier.background(Gray16)) { + TrackRowItemWrapper( + track = track, + onPlaySong = onPlaySong, + onDownloadSong = { onDownloadSong(track) }, + isSelected = track.id == currentTrackId, + downloadsEnabled = downloadsEnabled, + downloadState = downloadStates[track.id] ?: DownloadState.None, + onRemoveSong = { onRemoveSong(track) }, + ) + } + } } } } @@ -267,6 +315,21 @@ private fun NFTTracks( } } +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + style = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + brush = textGradient(SteelPink, CerisePink), + ), + ) +} + @OptIn(ExperimentalMaterialApi::class) @Composable private fun TrackRowItemWrapper( @@ -511,7 +574,8 @@ fun PreviewNftLibrary() { NFTLibraryScreenUi( state = NFTLibraryState.Content( - nftTracks = emptyList(), + ethereumTracks = emptyList(), + cardanoTracks = emptyList(), streamTokenTracks = emptyList(), showZeroResultFound = false, filters = diff --git a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt index 2a96a5e89..5f17f8e3a 100644 --- a/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt +++ b/android/app-newm/src/main/java/io/newm/screens/library/NFTLibraryState.kt @@ -14,7 +14,8 @@ sealed interface NFTLibraryState : CircuitUiState { data object EmptyWallet : NFTLibraryState data class Content( - val nftTracks: List, + val ethereumTracks: List, + val cardanoTracks: List, val streamTokenTracks: List, val showZeroResultFound: Boolean, val filters: NFTLibraryFilters, diff --git a/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailPresenter.kt index 47b3828d0..95419f0d8 100644 --- a/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailPresenter.kt @@ -1,14 +1,13 @@ package io.newm.screens.walletdetail import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import com.google.android.recaptcha.RecaptchaAction import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -16,14 +15,14 @@ import io.newm.screens.library.NFTLibraryState import io.newm.shared.NewmAppLogger import io.newm.shared.commonPublic.analytics.NewmAppEventLogger import io.newm.shared.commonPublic.analytics.events.AppScreens +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.NFTTrack import io.newm.shared.commonPublic.models.WalletConnection +import io.newm.shared.commonPublic.models.hasAllocationForWallet import io.newm.shared.commonPublic.models.mocks.EmptyWallet import io.newm.shared.commonPublic.usecases.FindWalletConnectionUseCase -import io.newm.shared.commonPublic.usecases.GetInvestmentPortfolioDataUseCase import io.newm.shared.commonPublic.usecases.SyncWalletConnectionsUseCase import io.newm.shared.commonPublic.usecases.WalletNFTTracksUseCase -import io.newm.sharedfeatures.screens.auth.login.RecaptchaClientProvider import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -36,8 +35,6 @@ class WalletDetailPresenter( private val findWalletConnectionUseCase: FindWalletConnectionUseCase, private val syncWalletConnectionsUseCase: SyncWalletConnectionsUseCase, private val nftTracksUseCase: WalletNFTTracksUseCase, - private val getPortfolioDataUseCase: GetInvestmentPortfolioDataUseCase, - private val recaptchaClientProvider: RecaptchaClientProvider, ) : Presenter { @Composable override fun present(): WalletDetailUiState { @@ -65,22 +62,7 @@ class WalletDetailPresenter( remember { nftTracksUseCase.getAllStreamTokensFlow() } .collectAsRetainedState(initial = emptyList()) - val claimableTokenAmount by - produceState(initialValue = 0L) { - val connection = walletConnection - if (connection != null) { - recaptchaClientProvider - .get() - .execute(RecaptchaAction.custom("get_earnings")) - .onSuccess { token -> - value = - getPortfolioDataUseCase.getInvestmentPortfolio( - walletAddress = connection.stakeAddress, - humanVerificationCode = token, - ) - }.onFailure { logger.error(TAG, "Error getting recaptcha token", it) } - } - } + LaunchedEffect(Unit) { nftTracksUseCase.refresh() } var isSyncing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -99,6 +81,7 @@ class WalletDetailPresenter( scope.launch { isSyncing = true syncWalletConnectionsUseCase.syncWalletConnectionsFromNetworkToDevice() + nftTracksUseCase.refresh() isSyncing = false } } @@ -116,14 +99,18 @@ class WalletDetailPresenter( } else -> { + val walletNftTracks = nftTracks.filter { it.hasAllocationForWallet(walletID) } + val walletStreamTokens = streamTokens.filter { it.hasAllocationForWallet(walletID) } + val ethereumTracks = walletNftTracks.filter { it.chainType == ChainType.Ethereum } + val cardanoTracks = walletNftTracks.filter { it.chainType == ChainType.Cardano } WalletDetailUiState.Content( eventSink, isSyncing, walletName, requireNotNull(walletConnection) { "Wallet connection should not be null" }, - nftTracks, - streamTokens, - claimableTokenAmount, + ethereumTracks, + cardanoTracks, + walletStreamTokens, ) } } diff --git a/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailUiState.kt b/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailUiState.kt index 4c3b3141e..48e7e684b 100644 --- a/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailUiState.kt +++ b/android/app-newm/src/main/java/io/newm/screens/walletdetail/WalletDetailUiState.kt @@ -26,8 +26,8 @@ sealed interface WalletDetailUiState : CircuitUiState { override val isSyncing: Boolean, override val walletName: String, val walletConnection: WalletConnection, - val nftTracks: List, + val ethereumTracks: List, + val cardanoTracks: List, val streamTokens: List, - val claimableTokenAmount: Long, ) : WalletDetailUiState } diff --git a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TokenItem.kt b/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TokenItem.kt deleted file mode 100644 index ed6fd66dd..000000000 --- a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TokenItem.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.newm.screens.walletdetail.view - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.newm.core.resources.R -import io.newm.core.ui.theme.GraySuit -import io.newm.core.ui.theme.NewmTheme -import io.newm.core.ui.theme.White - -fun LazyListScope.tokenItem(claimable: Long) { - item { Spacer(modifier = Modifier.height(8.dp)) } - item { - WalletDetailCard { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - Column { - Text( - text = stringResource(R.string.tokens), - style = - TextStyle( - fontFamily = FontFamily.Default, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = White, - ), - ) - Text( - text = "$$claimable", - style = - TextStyle( - fontFamily = FontFamily.Default, - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - color = GraySuit, - ), - ) - } - } - } - } -} - -@Preview(device = "id:pixel_9_pro_xl", showSystemUi = true) -@Composable -private fun Preview() { - NewmTheme { LazyColumn { tokenItem(123) } } -} diff --git a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TracksItem.kt b/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TracksItem.kt index c808dc800..2e701c4c0 100644 --- a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TracksItem.kt +++ b/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/TracksItem.kt @@ -36,3 +36,55 @@ fun LazyListScope.tracksItem( ) } } + +fun LazyListScope.ethereumTracksItem( + tracks: List, + isExpanded: Boolean, + headerShape: RoundedCornerShape, + onExitFinished: () -> Unit, + onClick: () -> Unit, +) { + if (tracks.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(8.dp)) } + collapsibleCard( + items = tracks, + isExpanded = isExpanded, + onExitFinished = onExitFinished, + header = { + WalletCardHeader( + title = stringResource(R.string.wallet_detail_ethereum_header), + headerShape = headerShape, + expanded = isExpanded, + onClick = onClick, + ) + }, + content = { WalletItem(it) }, + ) + } +} + +fun LazyListScope.cardanoTracksItem( + tracks: List, + isExpanded: Boolean, + headerShape: RoundedCornerShape, + onExitFinished: () -> Unit, + onClick: () -> Unit, +) { + if (tracks.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(8.dp)) } + collapsibleCard( + items = tracks, + isExpanded = isExpanded, + onExitFinished = onExitFinished, + header = { + WalletCardHeader( + title = stringResource(R.string.wallet_detail_cardano_header), + headerShape = headerShape, + expanded = isExpanded, + onClick = onClick, + ) + }, + content = { WalletItem(it) }, + ) + } +} diff --git a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/WalletDetailUi.kt b/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/WalletDetailUi.kt index fa01149e3..cfc86688d 100644 --- a/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/WalletDetailUi.kt +++ b/android/app-newm/src/main/java/io/newm/screens/walletdetail/view/WalletDetailUi.kt @@ -91,8 +91,10 @@ fun WalletDetailUi( @Composable private fun ContentWrapper(state: WalletDetailUiState.Content) { - var trackExpanded by remember { mutableStateOf(true) } - var trackHeaderShape by remember { mutableStateOf(HeaderExpandedShape) } + var ethereumExpanded by remember { mutableStateOf(true) } + var ethereumHeaderShape by remember { mutableStateOf(HeaderExpandedShape) } + var cardanoExpanded by remember { mutableStateOf(true) } + var cardanoHeaderShape by remember { mutableStateOf(HeaderExpandedShape) } var streamExpanded by remember { mutableStateOf(true) } var streamHeaderShape by remember { mutableStateOf(HeaderExpandedShape) } LazyColumn( @@ -100,17 +102,28 @@ private fun ContentWrapper(state: WalletDetailUiState.Content) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { - addressItem(state.walletConnection.stakeAddress) - tokenItem(state.claimableTokenAmount) - tracksItem( - tracks = state.nftTracks, - isExpanded = trackExpanded, - headerShape = trackHeaderShape, - onExitFinished = { trackHeaderShape = HeaderCollapsedShape }, + addressItem(state.walletConnection.address) + ethereumTracksItem( + tracks = state.ethereumTracks, + isExpanded = ethereumExpanded, + headerShape = ethereumHeaderShape, + onExitFinished = { ethereumHeaderShape = HeaderCollapsedShape }, onClick = { - trackExpanded = !trackExpanded - if (trackExpanded) { - trackHeaderShape = HeaderExpandedShape + ethereumExpanded = !ethereumExpanded + if (ethereumExpanded) { + ethereumHeaderShape = HeaderExpandedShape + } + }, + ) + cardanoTracksItem( + tracks = state.cardanoTracks, + isExpanded = cardanoExpanded, + headerShape = cardanoHeaderShape, + onExitFinished = { cardanoHeaderShape = HeaderCollapsedShape }, + onClick = { + cardanoExpanded = !cardanoExpanded + if (cardanoExpanded) { + cardanoHeaderShape = HeaderExpandedShape } }, ) diff --git a/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsEvent.kt b/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsEvent.kt index 04599e746..bca1a8168 100644 --- a/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsEvent.kt +++ b/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsEvent.kt @@ -22,5 +22,6 @@ sealed interface WalletsEvent : CircuitUiEvent { data class OnWalletDetailView( val walletId: String, + val name: String, ) : WalletsEvent } diff --git a/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsPresenter.kt b/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsPresenter.kt index a2ec7f471..bb02e8863 100644 --- a/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsPresenter.kt +++ b/android/app-newm/src/main/java/io/newm/screens/wallets/WalletsPresenter.kt @@ -18,6 +18,7 @@ import io.newm.shared.commonPublic.usecases.DisconnectWalletUseCase import io.newm.shared.commonPublic.usecases.GetWalletConnectionsUseCase import io.newm.shared.commonPublic.usecases.HasWalletConnectionsUseCase import io.newm.shared.commonPublic.usecases.SyncWalletConnectionsUseCase +import io.newm.shared.commonPublic.usecases.UpdateWalletNameUseCase import kotlinx.coroutines.launch class WalletsPresenter( @@ -27,6 +28,7 @@ class WalletsPresenter( private val disconnectWalletUseCase: DisconnectWalletUseCase, private val connectWalletUseCase: ConnectWalletUseCase, private val syncWalletConnectionsUseCase: SyncWalletConnectionsUseCase, + private val updateWalletNameUseCase: UpdateWalletNameUseCase, private val eventLogger: NewmAppEventLogger, ) : Presenter { @Composable @@ -73,11 +75,11 @@ class WalletsPresenter( is WalletsEvent.OnRenameWallet -> { eventLogger.logClickEvent(AppScreens.WalletsScreen.WALLET_RENAME_CONFIRM) - // TODO logic to rename wallet + scope.launch { updateWalletNameUseCase.updateName(it.walletId, it.newName) } } is WalletsEvent.OnWalletDetailView -> { - navigator.goTo(Screen.WalletDetail(it.walletId, "Wallet Name")) + navigator.goTo(Screen.WalletDetail(it.walletId, it.name)) } } } diff --git a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRow.kt b/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRow.kt deleted file mode 100644 index afc42b429..000000000 --- a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRow.kt +++ /dev/null @@ -1,52 +0,0 @@ -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.newm.core.resources.R -import io.newm.screens.profile.edit.ScrimCircle -import io.newm.shared.commonPublic.models.WalletConnection - -@Composable -fun WalletRow( - connection: WalletConnection, - onOptionsClick: (String) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(imageVector = Icons.Rounded.Info, contentDescription = "Wallet image placeholder") - Column(modifier = Modifier.weight(1f)) { - Text(text = connection.id, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) - Text( - text = connection.stakeAddress, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - } - Spacer(modifier = Modifier.weight(1f)) - ScrimCircle { - IconButton(onClick = { onOptionsClick(connection.id) }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = - stringResource(id = R.string.wallets_screen_wallet_options_desc), - ) - } - } - } -} diff --git a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRowItem.kt b/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRowItem.kt index f03dcbc01..d7135064b 100644 --- a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRowItem.kt +++ b/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletRowItem.kt @@ -16,7 +16,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -26,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -38,6 +38,7 @@ import io.newm.core.ui.theme.Gray16 import io.newm.core.ui.theme.GraySuit import io.newm.shared.commonPublic.analytics.NewmAppEventLogger import io.newm.shared.commonPublic.analytics.events.AppScreens +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.WalletConnection @Composable @@ -89,10 +90,21 @@ fun WalletRowItem( @Composable fun WalletRowItemDetails(connection: WalletConnection) { - Icon(imageVector = Icons.Rounded.Info, contentDescription = "Wallet image placeholder") + Icon( + painter = + painterResource( + id = + when (connection.chain) { + ChainType.Cardano -> R.drawable.ic_cardano + ChainType.Ethereum -> R.drawable.ic_ethereum + ChainType.Unknown -> R.drawable.ic_blockchain + }, + ), + contentDescription = "Wallet chain icon", + ) Column(modifier = Modifier.width(150.dp)) { Text( - text = connection.id, + text = connection.name, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, style = @@ -104,7 +116,7 @@ fun WalletRowItemDetails(connection: WalletConnection) { ), ) Text( - text = connection.stakeAddress, + text = connection.address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, style = diff --git a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletsUiContentView.kt b/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletsUiContentView.kt index 1bedc4d6a..9995e4faa 100644 --- a/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletsUiContentView.kt +++ b/android/app-newm/src/main/java/io/newm/screens/wallets/view/WalletsUiContentView.kt @@ -58,7 +58,7 @@ internal fun Content( eventLogger.logClickEvent( AppScreens.WalletsScreen.VIEW_DETAILS_WALLET_BUTTON, ) - state.eventSink(WalletsEvent.OnWalletDetailView(it.id)) + state.eventSink(WalletsEvent.OnWalletDetailView(it.id, it.name)) }, onRenameClick = { eventLogger.logClickEvent(AppScreens.WalletsScreen.RENAME_WALLET_BUTTON) @@ -76,7 +76,7 @@ internal fun Content( R.string.wallets_copy_address_label, it.id, // TODO ID should be replaced with wallet name ), - it.stakeAddress, + it.address, ), ), ) diff --git a/conductor/tracks.md b/conductor/tracks.md index c234f4e3f..015d5cf4d 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -11,3 +11,12 @@ This file tracks all major tracks for the project. Each track has its own detail - [x] **Track: Migrate CreateAccount and ResetPassword screens from android module to sharedfeatures module.** *Link: [./tracks/feature_login_migration_20260122/](./tracks/feature_login_migration_20260122/)* +--- + +- [ ] **Track: Update app to support ETH NFTs** + *Link: [./conductor/tracks/eth_nft_support_20260126/](./conductor/tracks/eth_nft_support_20260126/)* + +--- + +- [~] **Track: The NFT endpoint has been updated to include allocations so we can determine which wallet connections own specific NFTs. We need to update the codebase to properly persist this new data. If required, we should create and execute the appropriate database migration plan. Additionally, on the Wallet Details screen, we should ensure that only the assets associated with the selected wallet are displayed.** +*Link: [./tracks/nft_allocations_wallet_display_20260207/](./tracks/nft_allocations_wallet_display_20260207/)* \ No newline at end of file diff --git a/conductor/tracks/eth_nft_support_20260126/plan.md b/conductor/tracks/eth_nft_support_20260126/plan.md new file mode 100644 index 000000000..bb381b397 --- /dev/null +++ b/conductor/tracks/eth_nft_support_20260126/plan.md @@ -0,0 +1,10 @@ +# Plan: Update app to support ETH NFTs + +This track covers the necessary changes to support ETH NFTs and the related backend updates. + +## Subtasks + +- Add Ktor decompression plugin. +- Update WalletConnection data structure to use `address` instead of `stakeAddress`. +- Update NFT data structure to support multi-chain NFTs. +- Update API endpoint for fetching NFT songs to `/v1/nft/songs`. diff --git a/conductor/tracks/nft_allocations_wallet_display_20260207/index.md b/conductor/tracks/nft_allocations_wallet_display_20260207/index.md new file mode 100644 index 000000000..2345971dc --- /dev/null +++ b/conductor/tracks/nft_allocations_wallet_display_20260207/index.md @@ -0,0 +1,5 @@ +# Track nft_allocations_wallet_display_20260207 Context + +- [Specification](./spec.md) +- [Implementation Plan](./plan.md) +- [Metadata](./metadata.json) \ No newline at end of file diff --git a/conductor/tracks/nft_allocations_wallet_display_20260207/metadata.json b/conductor/tracks/nft_allocations_wallet_display_20260207/metadata.json new file mode 100644 index 000000000..4e66a3b11 --- /dev/null +++ b/conductor/tracks/nft_allocations_wallet_display_20260207/metadata.json @@ -0,0 +1,8 @@ +{ + "track_id": "nft_allocations_wallet_display_20260207", + "type": "feature", + "status": "new", + "created_at": "2026-02-07T10:00:00Z", + "updated_at": "2026-02-07T10:00:00Z", + "description": "The NFT endpoint has been updated to include allocations so we can determine which wallet connections own specific NFTs. We need to update the codebase to properly persist this new data. If required, we should create and execute the appropriate database migration plan. Additionally, on the Wallet Details screen, we should ensure that only the assets associated with the selected wallet are displayed." +} \ No newline at end of file diff --git a/conductor/tracks/nft_allocations_wallet_display_20260207/plan.md b/conductor/tracks/nft_allocations_wallet_display_20260207/plan.md new file mode 100644 index 000000000..1d060a6a0 --- /dev/null +++ b/conductor/tracks/nft_allocations_wallet_display_20260207/plan.md @@ -0,0 +1,34 @@ +# Implementation Plan: NFT Allocation Data & Wallet Details Screen Update + +## Phase 1: Database and Data Layer Integration +- [ ] Task: Update data model to include NFT allocation + - [~] Write Failing Tests (Red Phase): Create unit tests for the updated NFT data model, ensuring it can properly store and retrieve allocation information. + - [ ] Implement to Pass Tests (Green Phase): Modify the existing NFT data model (e.g., Kotlin data classes) to include allocation fields. + - [ ] Refactor: Review and refactor the data model for clarity and efficiency. +- [ ] Task: Implement database schema migration + - [ ] Write Failing Tests (Red Phase): Create integration tests that verify the database migration process for adding NFT allocation fields using SQLDelight. Ensure tests cover both initial schema creation and migration from an older schema. + - [ ] Implement to Pass Tests (Green Phase): Modify the SQLDelight schema files to include the new allocation fields in the NFT table. Implement any necessary schema migration logic. + - [ ] Refactor: Review and refactor the SQLDelight schema and migration logic. +- [ ] Task: Update data access layer (DAO/Repository) for NFT allocation + - [ ] Write Failing Tests (Red Phase): Create unit tests for the DAO/Repository methods to store and retrieve NFT allocation data, and to query NFTs by wallet and allocation. + - [ ] Implement to Pass Tests (Green Phase): Update the NFT DAO/Repository to handle saving and querying the new allocation data, including methods to filter NFTs by wallet ID. + - [ ] Refactor: Refactor DAO/Repository code for better readability and maintainability. +- [ ] Task: Conductor - User Manual Verification 'Database and Data Layer Integration' (Protocol in workflow.md) + +## Phase 2: Backend Communication and Data Synchronization +- [ ] Task: Update API client to consume new NFT endpoint response + - [ ] Write Failing Tests (Red Phase): Create unit tests for the API client to ensure it can correctly parse the updated NFT endpoint response, including the new allocation data. + - [ ] Implement to Pass Tests (Green Phase): Modify the API client (e.g., using Kotlin Serialization) to deserialize the new allocation fields from the NFT endpoint. + - [ ] Refactor: Refactor API client code for better error handling and maintainability. +- [ ] Task: Implement data synchronization logic + - [ ] Write Failing Tests (Red Phase): Create integration tests for the data synchronization logic, ensuring that NFT data, including allocations, is correctly fetched from the backend and persisted in the local database. + - [ ] Implement to Pass Tests (Green Phase): Implement or modify the synchronization logic to fetch NFT data from the updated endpoint and store it using the updated data access layer. + - [ ] Refactor: Optimize synchronization process for performance and reliability. +- [ ] Task: Conductor - User Manual Verification 'Backend Communication and Data Synchronization' (Protocol in workflow.md) + +## Phase 3: Wallet Details UI Update +- [ ] Task: Update Wallet Details screen to filter NFTs by selected wallet + - [ ] Write Failing Tests (Red Phase): Create Paparazzi UI tests to verify that the Wallet Details screen correctly displays only NFTs associated with the selected wallet, using the new allocation data. + - [ ] Implement to Pass Tests (Green Phase): Modify the Wallet Details screen's ViewModel and UI components to fetch and display NFTs based on the selected wallet's allocation data. + - [ ] Refactor: Improve UI code structure, ensure responsive design, and enhance user experience. +- [ ] Task: Conductor - User Manual Verification 'Wallet Details UI Update' (Protocol in workflow.md) \ No newline at end of file diff --git a/conductor/tracks/nft_allocations_wallet_display_20260207/spec.md b/conductor/tracks/nft_allocations_wallet_display_20260207/spec.md new file mode 100644 index 000000000..9cfca04ea --- /dev/null +++ b/conductor/tracks/nft_allocations_wallet_display_20260207/spec.md @@ -0,0 +1,22 @@ +# NFT Allocation Data & Wallet Details Screen Update + +## Overview +This track addresses the need to integrate updated NFT endpoint data, which now includes allocation information, into the application. This involves persisting the new allocation data in the local database and updating the Wallet Details screen to accurately display only the NFTs associated with the currently selected wallet. + +## Functional Requirements +1. **Data Persistence:** The application shall store the new NFT allocation data received from the updated NFT endpoint. +2. **Database Migration:** The application's database schema shall be updated to accommodate the new NFT allocation data using SQLDelight's schema evolution capabilities. +3. **Wallet Details Display:** The Wallet Details screen shall filter and display only the NFTs that are allocated to the currently selected wallet. + +## Non-Functional Requirements +1. **Performance:** The retrieval and display of NFT data on the Wallet Details screen should remain performant, even with the addition of allocation data. +2. **Data Integrity:** Ensure that the persisted NFT allocation data is consistent and accurate. + +## Acceptance Criteria +1. Given an updated NFT endpoint response containing allocation data, when the application syncs with the backend, then the new allocation data should be successfully stored in the local database. +2. Given the application has new NFT allocation data, when the Wallet Details screen is viewed for a specific wallet, then only NFTs associated with that wallet's allocations should be displayed. +3. Given a database with existing NFT data, when the application updates, then the database migration process for NFT allocation data should execute successfully without data loss. + +## Out of Scope +- Changes to the NFT endpoint on the server-side beyond what has already been implemented to include allocation data. +- Implementation of new features related to NFT management beyond displaying allocated NFTs on the Wallet Details screen. \ No newline at end of file diff --git a/core-resources/src/androidMain/res/drawable/ic_blockchain.xml b/core-resources/src/androidMain/res/drawable/ic_blockchain.xml new file mode 100644 index 000000000..8caf1e68b --- /dev/null +++ b/core-resources/src/androidMain/res/drawable/ic_blockchain.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core-resources/src/androidMain/res/drawable/ic_cardano.xml b/core-resources/src/androidMain/res/drawable/ic_cardano.xml new file mode 100644 index 000000000..e6e5cca8b --- /dev/null +++ b/core-resources/src/androidMain/res/drawable/ic_cardano.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core-resources/src/androidMain/res/drawable/ic_ethereum.xml b/core-resources/src/androidMain/res/drawable/ic_ethereum.xml new file mode 100644 index 000000000..497bbc118 --- /dev/null +++ b/core-resources/src/androidMain/res/drawable/ic_ethereum.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/core-resources/src/androidMain/res/values/strings.xml b/core-resources/src/androidMain/res/values/strings.xml index ca837f601..42d96b616 100644 --- a/core-resources/src/androidMain/res/values/strings.xml +++ b/core-resources/src/androidMain/res/values/strings.xml @@ -206,6 +206,8 @@ Copy Address Disconnect Music NFTs + Ethereum + Cardano Stream Tokens Address Tokens diff --git a/core-resources/src/commonMain/composeResources/values/strings.xml b/core-resources/src/commonMain/composeResources/values/strings.xml index 7eb200fb5..b0c3b6eaa 100644 --- a/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/core-resources/src/commonMain/composeResources/values/strings.xml @@ -206,6 +206,8 @@ Copy Address Disconnect Music NFTs + Ethereum + Cardano Stream Tokens Address Tokens diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9091780e..6448131f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -133,6 +133,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 677af8be1..9db8f5c2d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -52,6 +52,7 @@ kotlin { api(libs.ktor.client.content.negotiation) api(libs.ktor.serialization.kotlinx.json) api(libs.ktor.client.auth) + api(libs.ktor.client.encoding) implementation(libs.kotlinInject.runtime) implementation(libs.store5) implementation(libs.sqldelight.runtime) diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/CardanoWalletAPI.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NFTAPI.kt similarity index 57% rename from shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/CardanoWalletAPI.kt rename to shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NFTAPI.kt index 86b808c7f..95731710d 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/CardanoWalletAPI.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NFTAPI.kt @@ -3,19 +3,19 @@ package io.newm.shared.commonInternal.api import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get -import io.ktor.client.request.parameter import io.ktor.http.ContentType import io.ktor.http.contentType +import io.newm.shared.commonInternal.api.models.NFTSongResponse +import io.newm.shared.commonInternal.api.models.toDomainOrNull import io.newm.shared.commonPublic.models.NFTTrack import org.koin.core.component.KoinComponent -class CardanoWalletAPI( +class NFTAPI( private val authClient: HttpClient, ) : KoinComponent { suspend fun getWalletNFTs(): List = authClient - .get("/v1/cardano/nft/songs") { - contentType(ContentType.Application.Json) - parameter("legacy", true) - }.body() + .get("/v1/nft/songs") { contentType(ContentType.Application.Json) } + .body>() + .mapNotNull(NFTSongResponse::toDomainOrNull) } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NewmWalletConnectionAPI.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NewmWalletConnectionAPI.kt index 35adceb5d..bc10106b4 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NewmWalletConnectionAPI.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/NewmWalletConnectionAPI.kt @@ -4,11 +4,18 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.delete import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType import io.newm.shared.commonPublic.models.WalletConnection +import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent +@Serializable private data class UpdateWalletNameRequest( + val name: String, +) + class NEWMWalletConnectionAPI( private val authClient: HttpClient, ) : KoinComponent { @@ -27,4 +34,12 @@ class NEWMWalletConnectionAPI( authClient.delete("/v1/wallet-connections/$connectionId") { contentType(ContentType.Application.Json) } + + suspend fun updateWalletName( + connectionId: String, + name: String, + ) = authClient.patch("/v1/wallet-connections/$connectionId") { + contentType(ContentType.Application.Json) + setBody(UpdateWalletNameRequest(name)) + } } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/models/NFTSongResponse.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/models/NFTSongResponse.kt new file mode 100644 index 000000000..e33b02577 --- /dev/null +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/api/models/NFTSongResponse.kt @@ -0,0 +1,132 @@ +package io.newm.shared.commonInternal.api.models + +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType +import io.newm.shared.commonPublic.models.EthereumChainMetadata +import io.newm.shared.commonPublic.models.NFTAllocation +import io.newm.shared.commonPublic.models.NFTTrack +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +internal data class NFTSongResponse( + @SerialName("id") val id: String, + @SerialName("title") val title: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("audioUrl") val audioUrl: String, + @SerialName("duration") val duration: Long, + @SerialName("artists") val artists: List = emptyList(), + @SerialName("genres") val genres: List = emptyList(), + @SerialName("moods") val moods: List = emptyList(), + @SerialName("allocations") val allocations: List = emptyList(), + @SerialName("amount") val amount: Long, + @SerialName("chainMetadata") val chainMetadata: NFTChainMetadataResponse, +) + +internal fun NFTSongResponse.toDomainOrNull(): NFTTrack? = + when (val metadata = chainMetadata) { + is CardanoNFTChainMetadataResponse -> { + NFTTrack( + id = id, + title = title, + imageUrl = imageUrl, + audioUrl = audioUrl, + duration = duration, + artists = artists, + genres = genres, + moods = moods, + amount = amount, + chainType = ChainType.Cardano, + chainMetadata = + CardanoChainMetadata( + fingerprint = metadata.fingerprint, + policyId = metadata.policyId, + assetName = metadata.assetName, + isStreamToken = metadata.isStreamToken, + ), + allocations = allocations.map(NFTAllocationResponse::toDomain), + ) + } + + is EthereumNFTChainMetadataResponse -> { + NFTTrack( + id = id, + title = title, + imageUrl = imageUrl, + audioUrl = audioUrl, + duration = duration, + artists = artists, + genres = genres, + moods = moods, + amount = amount, + chainType = ChainType.Ethereum, + chainMetadata = + EthereumChainMetadata( + contractAddress = metadata.contractAddress, + tokenType = metadata.tokenType, + tokenId = metadata.tokenId, + ), + allocations = allocations.map(NFTAllocationResponse::toDomain), + ) + } + + is UnknownNFTChainMetadataResponse -> { + null + } + } + +@Serializable +internal data class NFTAllocationResponse( + @SerialName("id") val id: String, + @SerialName("amount") val amount: Long, +) + +internal fun NFTAllocationResponse.toDomain(): NFTAllocation = NFTAllocation(id = id, amount = amount, walletId = id) + +@Serializable(with = NFTChainMetadataResponseSerializer::class) +internal sealed interface NFTChainMetadataResponse { + val chain: String +} + +@Serializable +internal data class CardanoNFTChainMetadataResponse( + @SerialName("chain") override val chain: String, + @SerialName("fingerprint") val fingerprint: String, + @SerialName("policyId") val policyId: String, + @SerialName("assetName") val assetName: String, + @SerialName("isStreamToken") val isStreamToken: Boolean, +) : NFTChainMetadataResponse + +@Serializable +internal data class EthereumNFTChainMetadataResponse( + @SerialName("chain") override val chain: String, + @SerialName("contractAddress") val contractAddress: String, + @SerialName("tokenType") val tokenType: String, + @SerialName("tokenId") val tokenId: String, +) : NFTChainMetadataResponse + +@Serializable +internal data class UnknownNFTChainMetadataResponse( + @SerialName("chain") override val chain: String = ChainType.Unknown.serialName, +) : NFTChainMetadataResponse + +internal object NFTChainMetadataResponseSerializer : + JsonContentPolymorphicSerializer(NFTChainMetadataResponse::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + when ( + element.jsonObject["chain"] + ?.jsonPrimitive + ?.contentOrNull + ?.lowercase() + ) { + "cardano" -> CardanoNFTChainMetadataResponse.serializer() + "ethereum" -> EthereumNFTChainMetadataResponse.serializer() + else -> UnknownNFTChainMetadataResponse.serializer() + } +} diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/implementations/UpdateWalletNameUseCaseImpl.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/implementations/UpdateWalletNameUseCaseImpl.kt new file mode 100644 index 000000000..06dd90ecb --- /dev/null +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/implementations/UpdateWalletNameUseCaseImpl.kt @@ -0,0 +1,28 @@ +package io.newm.shared.commonInternal.implementations + +import io.newm.shared.commonInternal.implementations.utilities.mapErrorsSuspend +import io.newm.shared.commonInternal.repositories.WalletRepository +import io.newm.shared.commonPublic.models.error.KMMException +import io.newm.shared.commonPublic.usecases.UpdateWalletNameUseCase +import org.koin.core.component.KoinComponent +import shared.Notification +import shared.postNotification +import kotlin.coroutines.cancellation.CancellationException + +internal class UpdateWalletNameUseCaseImpl( + private val walletRepository: WalletRepository, +) : UpdateWalletNameUseCase, + KoinComponent { + @Throws(KMMException::class, CancellationException::class) + override suspend fun updateName( + walletConnectionId: String, + name: String, + ): Boolean = + mapErrorsSuspend { + val success = walletRepository.updateWalletName(walletConnectionId, name) + if (success) { + postNotification(Notification.WALLET_CONNECTION_STATE_CHANGED) + } + success + } +} diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/NFTRepository.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/NFTRepository.kt index b2ae5cea4..c4afb35f7 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/NFTRepository.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/NFTRepository.kt @@ -1,6 +1,8 @@ package io.newm.shared.commonInternal.repositories import io.newm.shared.commonInternal.store.NftTrackStore +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.NFTTrack import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +15,8 @@ import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse import org.mobilenativefoundation.store.store5.impl.extensions.get +private const val TAG = "NFTRepository" + internal class NFTRepository( private val nftStore: NftTrackStore, ) { @@ -31,13 +35,43 @@ internal class NFTRepository( _isSynced.value = true } - suspend fun getAllCollectableTracks(): List = nftStore.get(Unit).filterNot { it.isStreamToken } + suspend fun getAllCollectableTracks(): List = + nftStore + .get(Unit) + .filterNot { + val metadata = it.chainMetadata + metadata is CardanoChainMetadata && metadata.isStreamToken + }.sortedByChainType() - suspend fun getAllStreamTokens(): List = nftStore.get(Unit).filter { it.isStreamToken } + suspend fun getAllStreamTokens(): List = + nftStore.get(Unit).filter { + val metadata = it.chainMetadata + metadata is CardanoChainMetadata && metadata.isStreamToken + } - fun getAllCollectableTracksFlow(): Flow> = getAll().map { tracks -> tracks.filter { !it.isStreamToken } } + fun getAllCollectableTracksFlow(): Flow> = + getAll().map { tracks -> + val filtered = + tracks.filter { + val metadata = it.chainMetadata + !(metadata is CardanoChainMetadata && metadata.isStreamToken) + } + val sorted = filtered.sortedByChainType() + val ethereumCount = sorted.count { it.chainType == ChainType.Ethereum } + val cardanoCount = sorted.count { it.chainType == ChainType.Cardano } + println( + "$TAG: getAllCollectableTracksFlow: ${sorted.size} tracks ($ethereumCount Ethereum, $cardanoCount Cardano)", + ) + sorted + } - fun getAllStreamTokensFlow(): Flow> = getAll().map { tracks -> tracks.filter { it.isStreamToken } } + fun getAllStreamTokensFlow(): Flow> = + getAll().map { tracks -> + tracks.filter { + val metadata = it.chainMetadata + metadata is CardanoChainMetadata && metadata.isStreamToken + } + } @OptIn(ExperimentalStoreApi::class) suspend fun deleteAllTracksNFTsCache() { @@ -50,3 +84,13 @@ internal class NFTRepository( result.dataOrNull() ?: emptyList() } } + +/** Sorts NFT tracks with Ethereum tracks first, then Cardano, then Unknown. */ +private fun List.sortedByChainType(): List = + sortedBy { track -> + when (track.chainType) { + ChainType.Ethereum -> 0 + ChainType.Cardano -> 1 + ChainType.Unknown -> 2 + } + } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/WalletRepository.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/WalletRepository.kt index 409fd1ad6..b09197ce3 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/WalletRepository.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/repositories/WalletRepository.kt @@ -55,4 +55,21 @@ internal class WalletRepository( logger.error("WalletRepository", "Error disconnecting wallet ${e.cause}", e) false } + + suspend fun updateWalletName( + walletConnectionId: String, + name: String, + ): Boolean = + try { + val success = networkService.updateWalletName(walletConnectionId, name) + if (success) { + cacheService.updateWalletConnectionName(walletConnectionId, name) + } else { + throw KMMException("Error updating wallet name") + } + success + } catch (e: Exception) { + logger.error("WalletRepository", "Error updating wallet name ${e.cause}", e) + false + } } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/cache/WalletConnectionCacheService.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/cache/WalletConnectionCacheService.kt index 8dc19328e..7f95199c5 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/cache/WalletConnectionCacheService.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/cache/WalletConnectionCacheService.kt @@ -6,6 +6,7 @@ import io.newm.shared.commonInternal.services.db.deleteAllWalletConnections import io.newm.shared.commonInternal.services.db.deleteWalletConnectionById import io.newm.shared.commonInternal.services.db.findWalletConnectionByID import io.newm.shared.commonInternal.services.db.getWalletConnections +import io.newm.shared.commonInternal.services.db.updateWalletConnectionName import io.newm.shared.commonPublic.models.WalletConnection import kotlinx.coroutines.flow.Flow @@ -21,4 +22,9 @@ class WalletConnectionCacheService( suspend fun deleteAllWalletConnections() = db.deleteAllWalletConnections() suspend fun deleteWalletConnectionsById(id: String) = db.deleteWalletConnectionById(id) + + suspend fun updateWalletConnectionName( + connectionId: String, + name: String, + ) = db.updateWalletConnectionName(connectionId, name) } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/db/NewmDatabaseWrapperExt.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/db/NewmDatabaseWrapperExt.kt index a698c55ba..76e6d6888 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/db/NewmDatabaseWrapperExt.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/db/NewmDatabaseWrapperExt.kt @@ -3,10 +3,18 @@ package io.newm.shared.commonInternal.services.db import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOneOrNull +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType +import io.newm.shared.commonPublic.models.EthereumChainMetadata +import io.newm.shared.commonPublic.models.NFTAllocation import io.newm.shared.commonPublic.models.NFTTrack import io.newm.shared.commonPublic.models.WalletConnection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val TAG = "NFTDatabaseExt" fun NewmDatabaseWrapper.getAllTracks(): Flow> = invoke() @@ -15,34 +23,97 @@ fun NewmDatabaseWrapper.getAllTracks(): Flow> = .asFlow() .mapToList(kotlinx.coroutines.Dispatchers.Default) .map { tracksFromDb -> - tracksFromDb.map { track -> + println("$TAG: Reading ${tracksFromDb.size} tracks from database") + tracksFromDb.mapNotNull { track -> + val chainType = ChainType.fromString(track.chainType) + println( + "$TAG: Track '${track.title}' has chainType='${track.chainType}' -> parsed as $chainType", + ) + val chainMetadata = + when (chainType) { + ChainType.Cardano -> { + CardanoChainMetadata( + fingerprint = track.fingerprint.orEmpty(), + policyId = track.policyId.orEmpty(), + assetName = track.assetName.orEmpty(), + isStreamToken = track.isStreamToken == 1L, + ) + } + + ChainType.Ethereum -> { + EthereumChainMetadata( + contractAddress = track.contractAddress.orEmpty(), + tokenType = track.tokenType.orEmpty(), + tokenId = track.tokenId.orEmpty(), + ) + } + + ChainType.Unknown -> { + // Skip tracks with unknown chain types + return@mapNotNull null + } + } + val allocations: List = + try { + Json.decodeFromString(track.allocations) + } catch (_: Exception) { + emptyList() + } NFTTrack( id = track.id, - policyId = track.policyId, title = track.title, - assetName = track.assetName, amount = track.amount, imageUrl = track.imageUrl, audioUrl = track.audioUrl, duration = track.duration, artists = track.artists.split(","), genres = track.genres.split(","), - moods = track.genres.split(","), - isStreamToken = track.isStreamToken == 1L, + moods = track.moods.split(","), + chainType = chainType, + chainMetadata = chainMetadata, + allocations = allocations, ) } } fun NewmDatabaseWrapper.cacheNFTTracks(nftTracks: List) { + val ethereumCount = nftTracks.count { it.chainType == ChainType.Ethereum } + val cardanoCount = nftTracks.count { it.chainType == ChainType.Cardano } + println( + "$TAG: Caching ${nftTracks.size} NFT tracks: $ethereumCount Ethereum, $cardanoCount Cardano", + ) invoke().transaction { nftTracks.forEach { track -> + println( + "$TAG: Caching track '${track.title}' with chainType=${track.chainType} (serialName='${track.chainType.serialName}')", + ) + var policyId: String? = null + var assetName: String? = null + var fingerprint: String? = null + var isStreamToken: Long? = null + var contractAddress: String? = null + var tokenType: String? = null + var tokenId: String? = null + + when (track.chainMetadata) { + is CardanoChainMetadata -> { + policyId = track.chainMetadata.policyId + assetName = track.chainMetadata.assetName + fingerprint = track.chainMetadata.fingerprint + isStreamToken = if (track.chainMetadata.isStreamToken) 1L else 0L + } + + is EthereumChainMetadata -> { + contractAddress = track.chainMetadata.contractAddress + tokenType = track.chainMetadata.tokenType + tokenId = track.chainMetadata.tokenId + } + } invoke() .nFTTrackQueries .insertOrReplaceTrack( id = track.id, - policyId = track.policyId, title = track.title, - assetName = track.assetName, amount = track.amount, imageUrl = track.imageUrl, audioUrl = track.audioUrl, @@ -50,7 +121,15 @@ fun NewmDatabaseWrapper.cacheNFTTracks(nftTracks: List) { artists = track.artists.joinToString(","), genres = track.genres.joinToString(","), moods = track.moods.joinToString(","), - isStreamToken = if (track.isStreamToken) 1L else 0L, + chainType = track.chainType.serialName, + policyId = policyId, + assetName = assetName, + fingerprint = fingerprint, + isStreamToken = isStreamToken, + contractAddress = contractAddress, + tokenType = tokenType, + tokenId = tokenId, + allocations = Json.encodeToString(track.allocations), ) } } @@ -68,7 +147,13 @@ fun NewmDatabaseWrapper.findWalletConnectionByID(walletId: String): Flow if (db == null) return@map null - WalletConnection(id = db.id, createdAt = db.createdAt, stakeAddress = db.stakeAddress) + WalletConnection( + id = db.id, + createdAt = db.createdAt, + address = db.address, + chain = ChainType.fromString(db.chain), + name = db.name, + ) } } @@ -83,7 +168,9 @@ fun NewmDatabaseWrapper.getWalletConnections(): Flow> = WalletConnection( id = wallet.id, createdAt = wallet.createdAt, - stakeAddress = wallet.stakeAddress, + address = wallet.address, + chain = ChainType.fromString(wallet.chain), + name = wallet.name, ) } } @@ -96,7 +183,9 @@ fun NewmDatabaseWrapper.cacheWalletConnections(walletConnections: List = cardanoWalletAPI.getWalletNFTs() + suspend fun getWalletNFTs(): List { + val tracks = cardanoWalletAPI.getWalletNFTs() + val ethereumCount = tracks.count { it.chainType == ChainType.Ethereum } + val cardanoCount = tracks.count { it.chainType == ChainType.Cardano } + val unknownCount = tracks.count { it.chainType == ChainType.Unknown } + println( + "$TAG: Fetched ${tracks.size} NFTs from API: $ethereumCount Ethereum, $cardanoCount Cardano, $unknownCount Unknown", + ) + tracks.take(5).forEach { track -> + println("$TAG: Sample track: '${track.title}' chainType=${track.chainType}") + } + return tracks + } } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/network/WalletConnectionNetworkService.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/network/WalletConnectionNetworkService.kt index 0587fd04b..2a53a1bbf 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/network/WalletConnectionNetworkService.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonInternal/services/network/WalletConnectionNetworkService.kt @@ -14,4 +14,12 @@ internal class WalletConnectionNetworkService( val response = walletConnectionAPI.disconnectWallet(connectionId) return response.call.response.status.value == 204 } + + suspend fun updateWalletName( + connectionId: String, + name: String, + ): Boolean { + val response = walletConnectionAPI.updateWalletName(connectionId, name) + return response.status.value in 200..299 + } } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/featureflags/FeatureFlags.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/featureflags/FeatureFlags.kt index f6c784ce6..03925ea17 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/featureflags/FeatureFlags.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/featureflags/FeatureFlags.kt @@ -45,7 +45,7 @@ object FeatureFlags { override val displayName = "Multi-Wallet Support" override val description = "Enable multiple wallet connection and management" override val category = FlagCategory.BUSINESS_LOGIC - override val defaultValue = false + override val defaultValue = true } object ShowNEWMStudio : FeatureFlag { diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/ChainType.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/ChainType.kt new file mode 100644 index 000000000..4b38748c1 --- /dev/null +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/ChainType.kt @@ -0,0 +1,51 @@ +package io.newm.shared.commonPublic.models + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Represents the blockchain type for wallets and NFTs. + * + * Uses a custom serializer to handle string-based serialization while maintaining type safety. + * Unknown chain types from the backend are captured as [Unknown] to prevent crashes when new chains + * are added server-side before mobile app updates. + */ +@Serializable(with = ChainTypeSerializer::class) +enum class ChainType( + val serialName: String, +) { + Cardano("Cardano"), + Ethereum("Ethereum"), + Unknown("Unknown"), + ; + + companion object { + /** + * Converts a string value to a [ChainType]. Returns [Unknown] for unrecognized values + * instead of throwing. + */ + fun fromString(value: String): ChainType = + when { + value.equals("Cardano", ignoreCase = true) -> Cardano + value.equals("Ethereum", ignoreCase = true) -> Ethereum + else -> Unknown + } + } +} + +internal object ChainTypeSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("ChainType", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: ChainType, + ) { + encoder.encodeString(value.serialName) + } + + override fun deserialize(decoder: Decoder): ChainType = ChainType.fromString(decoder.decodeString()) +} diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/NFTTrack.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/NFTTrack.kt index ced81117f..9040646c9 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/NFTTrack.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/NFTTrack.kt @@ -10,9 +10,7 @@ import kotlinx.serialization.Serializable * associated image and song URLs, duration, and a list of artists involved. * * @property id Unique identifier of the NFT track. - * @property policyId Unique identifier of the policy associated with the NFT track. * @property title Name of the NFT track. - * @property assetName Name of the NFT track asset. * @property imageUrl URL of the image associated with the NFT track. * @property audioUrl URL of the song file associated with the NFT track. * @property duration Duration of the song in seconds. @@ -23,19 +21,47 @@ import kotlinx.serialization.Serializable * @property moods A list of moods associated with the NFT track. Defaults to an empty list if not * provided. */ +@Serializable +data class NFTAllocation( + @SerialName("id") val id: String, + @SerialName("amount") val amount: Long, + @SerialName("walletId") val walletId: String? = null, // Added walletId +) + @Serializable data class NFTTrack( @SerialName("id") val id: String, - @SerialName("policyId") val policyId: String, @SerialName("title") val title: String, - @SerialName("assetName") val assetName: String, - @SerialName("amount") val amount: Long, @SerialName("imageUrl") val imageUrl: String, @SerialName("audioUrl") val audioUrl: String, @SerialName("duration") val duration: Long, @SerialName("artists") val artists: List = emptyList(), @SerialName("genres") val genres: List, @SerialName("moods") val moods: List = emptyList(), - @SerialName("isStreamToken") val isStreamToken: Boolean, + @SerialName("amount") val amount: Long, + @SerialName("chainType") val chainType: ChainType, + @SerialName("chainMetadata") val chainMetadata: ChainMetadata, + @SerialName("allocations") val allocations: List = emptyList(), val isDownloaded: Boolean = false, ) + +fun NFTTrack.hasAllocationForWallet(walletId: String): Boolean = allocations.any { it.id == walletId || it.walletId == walletId } + +@Serializable sealed class ChainMetadata + +@Serializable +@SerialName("io.newm.server.features.nftsong.model.NftChainMetadata.Cardano") +data class CardanoChainMetadata( + val fingerprint: String, + val policyId: String, + val assetName: String, + val isStreamToken: Boolean, +) : ChainMetadata() + +@Serializable +@SerialName("io.newm.server.features.nftsong.model.NftChainMetadata.Ethereum") +data class EthereumChainMetadata( + val contractAddress: String, + val tokenType: String, + val tokenId: String, +) : ChainMetadata() diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/WalletConnection.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/WalletConnection.kt index b5cf3ed78..9f026fcfb 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/WalletConnection.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/WalletConnection.kt @@ -5,8 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class WalletConnection( val id: String, - // val localName: String, do we want to add the custom name here? - // val blockchainType: String, how we choose the wallet icon val createdAt: String, - val stakeAddress: String, + val address: String, + val chain: ChainType, + val name: String, ) diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/NFTTrackMocks.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/NFTTrackMocks.kt index 1fd519381..a40832e0b 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/NFTTrackMocks.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/NFTTrackMocks.kt @@ -1,5 +1,7 @@ package io.newm.shared.commonPublic.models.mocks +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.NFTTrack val baseTracks = @@ -118,9 +120,7 @@ private fun makeMockTrack( ): NFTTrack = NFTTrack( id = id, - policyId = "", title = name, - assetName = "", amount = 0, imageUrl = imageUrl, audioUrl = songUrl, @@ -128,5 +128,12 @@ private fun makeMockTrack( artists = artists, genres = arrayOf("Jazz").asList(), moods = arrayOf("Rock").asList(), - isStreamToken = false, + chainType = ChainType.Cardano, + chainMetadata = + CardanoChainMetadata( + fingerprint = "", + policyId = "", + assetName = "", + isStreamToken = false, + ), ) diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/WalletMocks.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/WalletMocks.kt index 2345caaa3..b72718713 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/WalletMocks.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/models/mocks/WalletMocks.kt @@ -1,7 +1,22 @@ package io.newm.shared.commonPublic.models.mocks +import io.newm.shared.commonPublic.models.ChainType import io.newm.shared.commonPublic.models.WalletConnection -val EmptyWallet = WalletConnection(id = "EMPTY", createdAt = "EMPTY", stakeAddress = "EMPTY") +val EmptyWallet = + WalletConnection( + id = "EMPTY", + createdAt = "EMPTY", + address = "EMPTY", + chain = ChainType.Cardano, + name = "Cardano Wallet Name", + ) -val ErrorWallet = WalletConnection(id = "ERROR", createdAt = "ERROR", stakeAddress = "ERROR") +val ErrorWallet = + WalletConnection( + id = "ERROR", + createdAt = "ERROR", + address = "ERROR", + chain = ChainType.Cardano, + name = "Cardano Wallet Name", + ) diff --git a/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/usecases/UpdateWalletNameUseCase.kt b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/usecases/UpdateWalletNameUseCase.kt new file mode 100644 index 000000000..f18589431 --- /dev/null +++ b/shared/src/commonMain/kotlin/io.newm.shared/commonPublic/usecases/UpdateWalletNameUseCase.kt @@ -0,0 +1,32 @@ +package io.newm.shared.commonPublic.usecases + +import io.newm.shared.commonPublic.models.error.KMMException +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.coroutines.cancellation.CancellationException + +/** + * `UpdateWalletNameUseCase` defines the contract for updating a wallet connection's display name. + */ +interface UpdateWalletNameUseCase { + /** + * Updates the display name of a wallet connection. + * + * @param walletConnectionId The ID of the wallet connection to update. + * @param name The new display name for the wallet connection. + * @return true if the update was successful, false otherwise. + * @throws KMMException If an application-specific error occurs. + * @throws CancellationException If the operation is cancelled. + */ + @Throws(KMMException::class, CancellationException::class) + suspend fun updateName( + walletConnectionId: String, + name: String, + ): Boolean +} + +class UpdateWalletNameUseCaseProvider : KoinComponent { + private val updateWalletNameUseCase: UpdateWalletNameUseCase by inject() + + fun get(): UpdateWalletNameUseCase = this.updateWalletNameUseCase +} diff --git a/shared/src/commonMain/kotlin/io.newm.shared/di/Json.kt b/shared/src/commonMain/kotlin/io.newm.shared/di/Json.kt new file mode 100644 index 000000000..3ad2ba273 --- /dev/null +++ b/shared/src/commonMain/kotlin/io.newm.shared/di/Json.kt @@ -0,0 +1,27 @@ +package io.newm.shared.di + +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainMetadata +import io.newm.shared.commonPublic.models.EthereumChainMetadata +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +val jsonModule = + SerializersModule { + polymorphic(ChainMetadata::class) { + subclass(CardanoChainMetadata::class) + subclass(EthereumChainMetadata::class) + } + } + +fun createJson() = + Json { + serializersModule = jsonModule + isLenient = true + ignoreUnknownKeys = true + encodeDefaults = true + // Note: Using default classDiscriminator ("type") since chainType is a separate field + // at the NFTTrack level, not the polymorphic discriminator inside chainMetadata + } diff --git a/shared/src/commonMain/kotlin/io.newm.shared/di/Koin.kt b/shared/src/commonMain/kotlin/io.newm.shared/di/Koin.kt index 90aa8bb4a..9156203b9 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/di/Koin.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/di/Koin.kt @@ -5,10 +5,10 @@ import io.newm.shared.NewmAppLogger import io.newm.shared.commonInternal.EarningsAPI import io.newm.shared.commonInternal.SessionManager import io.newm.shared.commonInternal.TokenManager -import io.newm.shared.commonInternal.api.CardanoWalletAPI import io.newm.shared.commonInternal.api.GenresAPI import io.newm.shared.commonInternal.api.LoginAPI import io.newm.shared.commonInternal.api.NEWMWalletConnectionAPI +import io.newm.shared.commonInternal.api.NFTAPI import io.newm.shared.commonInternal.api.NewmCloudinaryAPI import io.newm.shared.commonInternal.api.PlaylistAPI import io.newm.shared.commonInternal.api.RemoteConfigAPI @@ -28,6 +28,7 @@ import io.newm.shared.commonInternal.implementations.ResetPasswordUseCaseImpl import io.newm.shared.commonInternal.implementations.SignupUseCaseImpl import io.newm.shared.commonInternal.implementations.SyncWalletConnectionsUseCaseImpl import io.newm.shared.commonInternal.implementations.UpdateProfilePictureUseCaseImpl +import io.newm.shared.commonInternal.implementations.UpdateWalletNameUseCaseImpl import io.newm.shared.commonInternal.implementations.UserDetailsUseCaseImpl import io.newm.shared.commonInternal.implementations.UserSessionUseCaseImpl import io.newm.shared.commonInternal.implementations.WalletNFTTracksUseCaseImpl @@ -64,6 +65,7 @@ import io.newm.shared.commonPublic.usecases.ResetPasswordUseCase import io.newm.shared.commonPublic.usecases.SignupUseCase import io.newm.shared.commonPublic.usecases.SyncWalletConnectionsUseCase import io.newm.shared.commonPublic.usecases.UpdateProfilePictureUseCase +import io.newm.shared.commonPublic.usecases.UpdateWalletNameUseCase import io.newm.shared.commonPublic.usecases.UserDetailsUseCase import io.newm.shared.commonPublic.usecases.UserSessionUseCase import io.newm.shared.commonPublic.usecases.WalletNFTTracksUseCase @@ -116,7 +118,7 @@ fun commonModule(enableNetworkLogs: Boolean) = single { SessionManager(get(), get(), get()) } single { DefaultFeatureFlagService(get(), get(), get(), get(), get()) } // Internal API Services - single { CardanoWalletAPI(get(named("auth"))) } + single { NFTAPI(get(named("auth"))) } single { EarningsAPI(get(named("auth")), get()) } single { GenresAPI(get()) } single { LoginAPI(get(named("public")), get()) } @@ -157,18 +159,12 @@ fun commonModule(enableNetworkLogs: Boolean) = single { SignupUseCaseImpl(get()) } single { SyncWalletConnectionsUseCaseImpl(get()) } single { UpdateProfilePictureUseCaseImpl(get(), get()) } + single { UpdateWalletNameUseCaseImpl(get()) } single { UserDetailsUseCaseImpl(get()) } single { UserSessionUseCaseImpl(get()) } single { WalletNFTTracksUseCaseImpl(get()) } } -fun createJson() = - Json { - isLenient = true - ignoreUnknownKeys = true - encodeDefaults = true - } - internal fun createHttpClient( httpClientEngine: HttpClientEngine, json: Json, diff --git a/shared/src/commonMain/kotlin/io.newm.shared/di/NetworkClientFactory.kt b/shared/src/commonMain/kotlin/io.newm.shared/di/NetworkClientFactory.kt index 8c76a1e4e..6d72d11ea 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/di/NetworkClientFactory.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/di/NetworkClientFactory.kt @@ -7,6 +7,7 @@ import io.ktor.client.plugins.api.createClientPlugin import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel @@ -60,6 +61,7 @@ class NetworkClientFactory( this.expectSuccess = true defaultRequest { url(buildConfig.baseUrl) } install(ContentNegotiation) { json(json) } + install(ContentEncoding) if (enableNetworkLogs) { install(Logging) { logger = Logger.SIMPLE @@ -84,6 +86,7 @@ class NetworkClientFactory( defaultRequest { url(buildConfig.baseUrl) } install(ContentNegotiation) { json(json) } + install(ContentEncoding) if (enableNetworkLogs) { install(Logging) { logger = Logger.SIMPLE diff --git a/shared/src/commonMain/kotlin/io.newm.shared/di/dagger/NetworkServiceComponent.kt b/shared/src/commonMain/kotlin/io.newm.shared/di/dagger/NetworkServiceComponent.kt index 01ac38a13..eda8f9059 100644 --- a/shared/src/commonMain/kotlin/io.newm.shared/di/dagger/NetworkServiceComponent.kt +++ b/shared/src/commonMain/kotlin/io.newm.shared/di/dagger/NetworkServiceComponent.kt @@ -2,9 +2,9 @@ package io.newm.shared.di.dagger import io.newm.shared.NewmAppLogger import io.newm.shared.commonInternal.EarningsAPI -import io.newm.shared.commonInternal.api.CardanoWalletAPI import io.newm.shared.commonInternal.api.LoginAPI import io.newm.shared.commonInternal.api.NEWMWalletConnectionAPI +import io.newm.shared.commonInternal.api.NFTAPI import io.newm.shared.commonInternal.api.NewmCloudinaryAPI import io.newm.shared.commonInternal.api.PlaylistAPI import io.newm.shared.commonInternal.api.RemoteConfigAPI @@ -21,10 +21,9 @@ import me.tatarka.inject.annotations.Provides interface NetworkServiceComponent { @Provides - fun provideCardanoWalletAPI(authHttpClient: AuthHttpClient): CardanoWalletAPI = CardanoWalletAPI(authHttpClient.client) + fun provideNFTAPI(authHttpClient: AuthHttpClient): NFTAPI = NFTAPI(authHttpClient.client) - @Provides - fun providesNFTNetworkService(api: CardanoWalletAPI): NFTNetworkService = NFTNetworkService(api) + @Provides fun providesNFTNetworkService(api: NFTAPI): NFTNetworkService = NFTNetworkService(api) @Provides fun provideEarningsAPI( diff --git a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/4.sqm b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/4.sqm new file mode 100644 index 000000000..f46f1dbe5 --- /dev/null +++ b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/4.sqm @@ -0,0 +1,39 @@ +-- Migration 4: Add multi-chain (Ethereum) NFT support +-- NFTTrack: Add chainType, fingerprint, contractAddress, tokenType, tokenId; make policyId, assetName, isStreamToken nullable +-- WalletConnection: Replace stakeAddress with address, chain, name + +-- Recreate NFTTrack table with new schema +DROP TABLE IF EXISTS NFTTrack; + +CREATE TABLE NFTTrack ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + amount INTEGER NOT NULL, + imageUrl TEXT NOT NULL, + audioUrl TEXT NOT NULL, + duration INTEGER NOT NULL, + artists TEXT NOT NULL, + genres TEXT NOT NULL, + moods TEXT NOT NULL, + chainType TEXT NOT NULL, + -- Cardano specific + policyId TEXT, + assetName TEXT, + fingerprint TEXT, + isStreamToken INTEGER, + -- Ethereum specific + contractAddress TEXT, + tokenType TEXT, + tokenId TEXT +); + +-- Recreate WalletConnection table with new schema +DROP TABLE IF EXISTS WalletConnection; + +CREATE TABLE WalletConnection ( + id TEXT NOT NULL PRIMARY KEY, + createdAt TEXT NOT NULL, + address TEXT NOT NULL, + chain TEXT NOT NULL, + name TEXT NOT NULL +); diff --git a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/5.sqm b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/5.sqm new file mode 100644 index 000000000..a9e54b65a --- /dev/null +++ b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/5.sqm @@ -0,0 +1 @@ +ALTER TABLE NFTTrack ADD COLUMN allocations TEXT NOT NULL DEFAULT '[]'; diff --git a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/6.sqm b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/6.sqm new file mode 100644 index 000000000..45658c344 --- /dev/null +++ b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/6.sqm @@ -0,0 +1 @@ +DROP TABLE IF EXISTS WalletNFTAllocation; diff --git a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/NFTTrack.sq b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/NFTTrack.sq index 05216b086..fc4528922 100644 --- a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/NFTTrack.sq +++ b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/NFTTrack.sq @@ -2,9 +2,7 @@ CREATE TABLE NFTTrack ( id TEXT PRIMARY KEY, - policyId TEXT NOT NULL, title TEXT NOT NULL, - assetName TEXT NOT NULL, amount INTEGER NOT NULL, imageUrl TEXT NOT NULL, audioUrl TEXT NOT NULL, @@ -12,7 +10,18 @@ CREATE TABLE NFTTrack ( artists TEXT NOT NULL, genres TEXT NOT NULL, moods TEXT NOT NULL, - isStreamToken INTEGER NOT NULL DEFAULT 0 + chainType TEXT NOT NULL, + -- Cardano specific + policyId TEXT, + assetName TEXT, + fingerprint TEXT, + isStreamToken INTEGER, + -- Ethereum specific + contractAddress TEXT, + tokenType TEXT, + tokenId TEXT, + -- Allocations (JSON array) + allocations TEXT NOT NULL DEFAULT '[]' ); -- Select all tracks @@ -22,8 +31,8 @@ FROM NFTTrack; -- Insert or replace a track insertOrReplaceTrack: -REPLACE INTO NFTTrack (id, policyId, title, assetName, amount, imageUrl, audioUrl, duration, artists, genres, moods, isStreamToken) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +REPLACE INTO NFTTrack (id, title, amount, imageUrl, audioUrl, duration, artists, genres, moods, chainType, policyId, assetName, fingerprint, isStreamToken, contractAddress, tokenType, tokenId, allocations) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- Delete all tracks deleteAll: diff --git a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/WalletConnection.sq b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/WalletConnection.sq index fa9302283..684e13925 100644 --- a/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/WalletConnection.sq +++ b/shared/src/commonMain/sqldelight/io.newm.shared.db.cache/WalletConnection.sq @@ -2,7 +2,9 @@ CREATE TABLE WalletConnection ( id TEXT NOT NULL PRIMARY KEY, createdAt TEXT NOT NULL, - stakeAddress TEXT NOT NULL + address TEXT NOT NULL, + chain TEXT NOT NULL, + name TEXT NOT NULL ); -- Fetches all WalletConnections from the database @@ -12,8 +14,8 @@ FROM WalletConnection; -- Inserts a new WalletConnection into the database insert: -INSERT OR IGNORE INTO WalletConnection(id, createdAt, stakeAddress) -VALUES (?, ?, ?); +INSERT OR IGNORE INTO WalletConnection(id, createdAt, address, chain, name) +VALUES (?, ?, ?, ?, ?); -- Finds a WalletConnection by ID findWalletConnectionById: @@ -29,3 +31,9 @@ WHERE id = ?; -- Deletes all WalletConnections from the database deleteAll: DELETE FROM WalletConnection; + +-- Updates the name of a WalletConnection by ID +updateNameById: +UPDATE WalletConnection +SET name = ? +WHERE id = ?; diff --git a/shared/src/commonTest/kotlin/NFTAllocationTest.kt b/shared/src/commonTest/kotlin/NFTAllocationTest.kt new file mode 100644 index 000000000..7ceb5b727 --- /dev/null +++ b/shared/src/commonTest/kotlin/NFTAllocationTest.kt @@ -0,0 +1,103 @@ +package io.newm.shared + +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType +import io.newm.shared.commonPublic.models.EthereumChainMetadata +import io.newm.shared.commonPublic.models.NFTAllocation +import io.newm.shared.commonPublic.models.NFTTrack +import io.newm.shared.di.createJson +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class NFTAllocationSerializationTest { + private val json = createJson() + + @Test + fun testSingleNFTAllocationSerialization() { + val walletId = "single_wallet_id" + val allocation = NFTAllocation(amount = 5, id = "single_allocation_id", walletId = walletId) + + val jsonString = json.encodeToString(allocation) + val deserializedAllocation = json.decodeFromString(jsonString) + + assertNotNull(deserializedAllocation) + assertEquals(allocation.id, deserializedAllocation.id) + assertEquals(allocation.amount, deserializedAllocation.amount) + assertEquals(allocation.walletId, deserializedAllocation.walletId) + } + + @Test + fun testNFTAllocationWithWalletId_serialization() { + val walletId1 = "wallet_id_1" + val walletId2 = "wallet_id_2" + val nftId = "nft_id_1" + + val allocations = + listOf( + NFTAllocation(amount = 1, id = "allocation_id_1", walletId = walletId1), + NFTAllocation(amount = 2, id = "allocation_id_2", walletId = walletId2), + ) + + val nftTrack = + NFTTrack( + id = nftId, + title = "Title 1", + imageUrl = "image_url_1", + audioUrl = "audio_url_1", + duration = 180L, + artists = listOf("Artist 1"), + genres = listOf("Pop"), + moods = listOf("Happy"), + amount = 10L, + chainType = ChainType.Cardano, + chainMetadata = + CardanoChainMetadata("fingerprint_1", "policy_id_1", "asset_name_1", true), + allocations = allocations, + ) + + val jsonString = json.encodeToString(nftTrack.allocations) + val deserializedAllocations = json.decodeFromString>(jsonString) + + assertEquals(2, deserializedAllocations.size) + assertEquals(walletId1, deserializedAllocations[0].walletId) + assertEquals(walletId2, deserializedAllocations[1].walletId) + } + + @Test + fun testNFTTrackSerializationAndDeserialization() { + val nftId = "nft_id_2" + val walletId = "some_wallet_id" + + val allocations = + listOf(NFTAllocation(amount = 3, id = "allocation_id_3", walletId = walletId)) + + val nftTrack = + NFTTrack( + id = nftId, + title = "Title 2", + imageUrl = "image_url_2", + audioUrl = "audio_url_2", + duration = 200L, + artists = listOf("Artist 2"), + genres = listOf("Rock"), + moods = listOf("Energetic"), + amount = 15L, + chainType = ChainType.Ethereum, + chainMetadata = + EthereumChainMetadata("contract_address_1", "ERC-721", "token_id_1"), + allocations = allocations, + ) + + val jsonString = json.encodeToString(nftTrack) + val deserializedNftTrack = json.decodeFromString(jsonString) + + assertNotNull(deserializedNftTrack) + assertEquals(nftTrack.id, deserializedNftTrack.id) + assertEquals(nftTrack.title, deserializedNftTrack.title) + assertEquals(nftTrack.allocations.size, deserializedNftTrack.allocations.size) + assertEquals(walletId, deserializedNftTrack.allocations[0].walletId) + } +} diff --git a/shared/src/commonTest/kotlin/NFTSongResponseDecodingTest.kt b/shared/src/commonTest/kotlin/NFTSongResponseDecodingTest.kt new file mode 100644 index 000000000..53fc87ce0 --- /dev/null +++ b/shared/src/commonTest/kotlin/NFTSongResponseDecodingTest.kt @@ -0,0 +1,86 @@ +package io.newm.shared + +import io.newm.shared.commonInternal.api.models.CardanoNFTChainMetadataResponse +import io.newm.shared.commonInternal.api.models.EthereumNFTChainMetadataResponse +import io.newm.shared.commonInternal.api.models.NFTSongResponse +import io.newm.shared.commonInternal.api.models.toDomainOrNull +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType +import io.newm.shared.commonPublic.models.EthereumChainMetadata +import io.newm.shared.di.createJson +import kotlinx.serialization.decodeFromString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class NFTSongResponseDecodingTest { + @Test + fun `decode wiki response shape into domain nft tracks`() { + val payload = + """ + [ + { + "id": "cf42d678-e7e7-3986-9cca-3f2d42461c5f", + "title": "Daisuke", + "imageUrl": "https://example.com/cardano.png", + "audioUrl": "https://example.com/cardano.mp3", + "duration": 200, + "artists": ["Danketsu", "Mirai Music", "NSTASIA"], + "genres": ["Pop", "House", "Tribal"], + "moods": ["Spiritual"], + "amount": 1000000, + "allocations": [ + { + "id": "170c0a9b-0216-4429-b8a5-15a094dd2e38", + "amount": 400000 + } + ], + "chainMetadata": { + "chain": "Cardano", + "fingerprint": "asset1effvlkkw02m9ft3ymlkfld8mhlq05wc2hal5du", + "policyId": "46e607b3046a34c95e7c29e47047618dbf5e10de777ba56c590cfd5c", + "assetName": "NEWM_5", + "isStreamToken": true + } + }, + { + "id": "4faf291f-060d-318a-a1d9-3e64288127c4", + "title": "A Little Rain Must Fall", + "imageUrl": "https://example.com/ethereum.png", + "audioUrl": "https://example.com/ethereum.mp3", + "duration": -1, + "artists": ["Violetta Zironi"], + "genres": [], + "moods": [], + "amount": 1, + "allocations": [ + { + "id": "f524574f-4377-48da-80eb-f116d0d05c76", + "amount": 1 + } + ], + "chainMetadata": { + "chain": "Ethereum", + "contractAddress": "0x328B49C56a8A15fb34aB3eCD8883Fac5F9512453", + "tokenType": "ERC721", + "tokenId": "107" + } + } + ] + """.trimIndent() + + val responses = createJson().decodeFromString>(payload) + val tracks = responses.mapNotNull(NFTSongResponse::toDomainOrNull) + + assertEquals(2, tracks.size) + assertEquals(ChainType.Cardano, tracks[0].chainType) + assertEquals("170c0a9b-0216-4429-b8a5-15a094dd2e38", tracks[0].allocations.single().id) + assertEquals(ChainType.Ethereum, tracks[1].chainType) + assertEquals("f524574f-4377-48da-80eb-f116d0d05c76", tracks[1].allocations.single().id) + + assertIs(responses[0].chainMetadata) + assertIs(responses[1].chainMetadata) + assertIs(tracks[0].chainMetadata) + assertIs(tracks[1].chainMetadata) + } +} diff --git a/shared/src/commonTest/kotlin/NFTTrackWalletAllocationTest.kt b/shared/src/commonTest/kotlin/NFTTrackWalletAllocationTest.kt new file mode 100644 index 000000000..32d3d660c --- /dev/null +++ b/shared/src/commonTest/kotlin/NFTTrackWalletAllocationTest.kt @@ -0,0 +1,40 @@ +package io.newm.shared + +import io.newm.shared.commonPublic.models.CardanoChainMetadata +import io.newm.shared.commonPublic.models.ChainType +import io.newm.shared.commonPublic.models.NFTAllocation +import io.newm.shared.commonPublic.models.NFTTrack +import io.newm.shared.commonPublic.models.hasAllocationForWallet +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NFTTrackWalletAllocationTest { + @Test + fun `hasAllocationForWallet matches allocation id as wallet connection id`() { + val track = + NFTTrack( + id = "track-1", + title = "Track", + imageUrl = "image", + audioUrl = "audio", + duration = 180L, + artists = listOf("Artist"), + genres = listOf("Genre"), + moods = listOf("Mood"), + amount = 1L, + chainType = ChainType.Cardano, + chainMetadata = + CardanoChainMetadata( + fingerprint = "fingerprint", + policyId = "policy-id", + assetName = "asset-name", + isStreamToken = false, + ), + allocations = listOf(NFTAllocation(id = "wallet-123", amount = 1L)), + ) + + assertTrue(track.hasAllocationForWallet("wallet-123")) + assertFalse(track.hasAllocationForWallet("wallet-999")) + } +}