diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt new file mode 100644 index 000000000..814a7fb3f --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -0,0 +1,9 @@ +package to.bitkit.ext + +import uniffi.bitkitcore.Activity + +val Activity.idValue: String + get() = when (this) { + is Activity.Lightning -> v1.id + is Activity.Onchain -> v1.txId + } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 7b5b701e7..323a3fd45 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -21,6 +21,7 @@ import to.bitkit.data.entities.InvoiceTagEntity import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.idValue import to.bitkit.ext.toHex import to.bitkit.models.BalanceState import to.bitkit.models.NodeLifecycleState @@ -529,12 +530,6 @@ class WalletRepo @Inject constructor( is Activity.Onchain -> paymentHashOrTxId == v1.txId } - private val Activity.idValue: String - get() = when (this) { - is Activity.Lightning -> v1.id - is Activity.Onchain -> v1.txId - } - private suspend fun Scanner.OnChain.extractLightningHashOrAddress(): String { val address = this.invoice.address val lightningInvoice: String = this.invoice.params?.get("lightning") ?: address diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 56772f076..eac7977f9 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -59,6 +59,7 @@ import to.bitkit.ui.screens.transfer.external.ExternalFeeCustomScreen import to.bitkit.ui.screens.transfer.external.ExternalSuccessScreen import to.bitkit.ui.screens.wallets.HomeScreen import to.bitkit.ui.screens.wallets.activity.ActivityItemScreen +import to.bitkit.ui.screens.wallets.activity.ActivityExploreScreen import to.bitkit.ui.settings.BackupSettingsScreen import to.bitkit.ui.settings.BlocktankRegtestScreen import to.bitkit.ui.settings.BlocktankRegtestViewModel @@ -642,7 +643,17 @@ private fun NavGraphBuilder.activityItem( ActivityItemScreen( viewModel = activityListViewModel, activityItem = navBackEntry.toRoute(), + onExploreClick = { id -> navController.navigateToActivityExplore(id) }, onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.navigateToHome() }, + ) + } + composableWithDefaultTransitions { navBackEntry -> + ActivityExploreScreen( + viewModel = activityListViewModel, + route = navBackEntry.toRoute(), + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.navigateToHome() }, ) } } @@ -819,6 +830,10 @@ fun NavController.navigateToActivityItem(id: String) = navigate( route = Routes.ActivityItem(id), ) +fun NavController.navigateToActivityExplore(id: String) = navigate( + route = Routes.ActivityExplore(id), +) + fun NavController.navigateToQrScanner() = navigate( route = Routes.QrScanner, ) @@ -990,6 +1005,9 @@ object Routes { @Serializable data class ActivityItem(val id: String) + @Serializable + data class ActivityExplore(val id: String) + @Serializable data object QrScanner } diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index d2e213f4c..9b4c2c826 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp @@ -240,11 +241,16 @@ fun BodySSB( text: String, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + ) { BodySSB( text = AnnotatedString(text), color = color, modifier = modifier, + maxLines = maxLines, + overflow = overflow, ) } @@ -253,6 +259,8 @@ fun BodySSB( text: AnnotatedString, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, ) { Text( text = text, @@ -266,6 +274,8 @@ fun BodySSB( textAlign = TextAlign.Start, ), modifier = modifier, + maxLines = maxLines, + overflow = overflow, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/DevSettingsScreen.kt index fe21c5e22..715f3363e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/DevSettingsScreen.kt @@ -57,7 +57,7 @@ fun DevSettingsScreen( NodeDetails(uiState) InfoField( value = uiState.onchainAddress, - label = stringResource(R.string.address), + label = stringResource(R.string.wallet__activity_address), maxLength = 36, trailingIcon = { Row { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 48ade891f..f6533e164 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -59,7 +59,7 @@ import to.bitkit.ui.navigateToTransferSavingsIntro import to.bitkit.ui.navigateToTransferSpendingAmount import to.bitkit.ui.navigateToTransferSpendingIntro import to.bitkit.ui.scaffold.AppScaffold -import to.bitkit.ui.screens.wallets.activity.ActivityList +import to.bitkit.ui.screens.wallets.activity.HomeActivityList import to.bitkit.ui.screens.wallets.activity.AllActivityScreen import to.bitkit.ui.screens.wallets.activity.DateRangeSelectorSheet import to.bitkit.ui.screens.wallets.activity.TagSelectorSheet @@ -266,7 +266,7 @@ private fun HomeContentView( Spacer(modifier = Modifier.height(16.dp)) val activity = activityListViewModel ?: return@Column val latestActivities by activity.latestActivities.collectAsStateWithLifecycle() - ActivityList( + HomeActivityList( items = latestActivities, onAllActivityClick = { walletNavController.navigate(HomeRoutes.AllActivity) }, onActivityItemClick = { rootNavController.navigateToActivityItem(it) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt new file mode 100644 index 000000000..ba5c7d3f0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -0,0 +1,347 @@ +package to.bitkit.ui.screens.wallets.activity + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.lightningdevkit.ldknode.Network +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.idValue +import to.bitkit.models.Toast +import to.bitkit.ui.Routes +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.ui.utils.getScreenTitleRes +import to.bitkit.ui.utils.localizedPlural +import to.bitkit.utils.TxDetails +import to.bitkit.viewmodels.ActivityDetailViewModel +import to.bitkit.viewmodels.ActivityListViewModel +import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.LightningActivity +import uniffi.bitkitcore.OnchainActivity +import uniffi.bitkitcore.PaymentState +import uniffi.bitkitcore.PaymentType + +@Composable +fun ActivityExploreScreen( + viewModel: ActivityListViewModel, + route: Routes.ActivityExplore, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, +) { + val activities by viewModel.filteredActivities.collectAsStateWithLifecycle() + val item = activities?.find { it.idValue == route.id } + ?: return + + val app = appViewModel ?: return + val detailViewModel: ActivityDetailViewModel = hiltViewModel() + val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() + val copyToastTitle = stringResource(R.string.common__copied) + + LaunchedEffect(item) { + if (item is Activity.Onchain) { + detailViewModel.fetchTransactionDetails(item.v1.txId) + } else { + detailViewModel.clearTransactionDetails() + } + } + + DisposableEffect(Unit) { + onDispose { + detailViewModel.clearTransactionDetails() + } + } + + ScreenColumn { + AppTopBar( + titleText = stringResource(item.getScreenTitleRes()), + onBackClick = onBackClick, + actions = { CloseNavIcon(onClick = onCloseClick) }, + ) + ActivityExploreContent( + item = item, + txDetails = txDetails, + onCopy = { text -> + app.toast( + type = Toast.ToastType.SUCCESS, + title = copyToastTitle, + description = text.ellipsisMiddle(40) + ) + } + ) + } +} + +@Composable +private fun ActivityExploreContent( + item: Activity, + txDetails: TxDetails?, + onCopy: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + // Header + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + val value = when (item) { + is Activity.Lightning -> item.v1.value + is Activity.Onchain -> item.v1.value + } + val isSent = when (item) { + is Activity.Lightning -> item.v1.txType == PaymentType.SENT + is Activity.Onchain -> item.v1.txType == PaymentType.SENT + } + val amountPrefix = if (isSent) "-" else "+" + BalanceHeaderView( + sats = value.toLong(), + prefix = amountPrefix, + showBitcoinSymbol = false, + modifier = Modifier.weight(1f), + ) + ActivityIcon(activity = item, size = 48.dp) + } + + Spacer(modifier = Modifier.height(16.dp)) + + when (item) { + is Activity.Onchain -> { + OnchainDetails(onchain = item, onCopy = onCopy, txDetails = txDetails) + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + text = stringResource(R.string.wallet__activity_explorer), + onClick = handleExploreClick(item), + ) + } + + is Activity.Lightning -> { + LightningDetails(lightning = item, onCopy = onCopy) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun LightningDetails( + lightning: Activity.Lightning, + onCopy: (String) -> Unit, +) { + val paymentHash = lightning.v1.id + val preimage = lightning.v1.preimage + val invoice = lightning.v1.invoice + + if (!preimage.isNullOrEmpty()) { + Section( + title = stringResource(R.string.wallet__activity_preimage), + value = preimage, + modifier = Modifier.clickableAlpha(onClick = copyToClipboard(preimage) { + onCopy(preimage) + }), + ) + } + Section( + title = stringResource(R.string.wallet__activity_payment_hash), + value = paymentHash, + modifier = Modifier.clickableAlpha(onClick = copyToClipboard(paymentHash) { + onCopy(paymentHash) + }), + ) + Section( + title = stringResource(R.string.wallet__activity_invoice), + value = invoice, + modifier = Modifier.clickableAlpha(onClick = copyToClipboard(invoice) { + onCopy(invoice) + }), + ) +} + +@Composable +private fun ColumnScope.OnchainDetails( + onchain: Activity.Onchain, + onCopy: (String) -> Unit, + txDetails: TxDetails?, +) { + val txId = onchain.v1.txId + Section( + title = stringResource(R.string.wallet__activity_tx_id), + value = txId, + modifier = Modifier.clickableAlpha(onClick = copyToClipboard(txId) { + onCopy(txId) + }), + ) + if (txDetails != null) { + Section( + title = localizedPlural(R.string.wallet__activity_input, mapOf("count" to txDetails.vin.size)), + valueContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + txDetails.vin.forEach { input -> + val text = "${input.txid}:${input.vout}" + BodySSB(text = text) + } + } + }, + ) + Section( + title = localizedPlural(R.string.wallet__activity_output, mapOf("count" to txDetails.vout.size)), + valueContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + txDetails.vout.forEach { output -> + val address = output.scriptpubkey_address.orEmpty() + BodySSB(text = address, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + } + } + }, + ) + } else { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(vertical = 16.dp) + .align(Alignment.CenterHorizontally), + ) + } + // TODO add boosted parents info if boosted +} + +@Composable +private fun Section( + title: String, + modifier: Modifier = Modifier, + value: String? = null, + valueContent: (@Composable () -> Unit)? = null, +) { + Column(modifier = modifier) { + Caption13Up( + text = title, + color = Colors.White64, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + if (valueContent != null) { + valueContent() + } else if (value != null) { + BodySSB(text = value) + } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + } +} + +@Composable +private fun handleExploreClick( + onchain: Activity.Onchain, +): () -> Unit { + val context = LocalContext.current + val baseUrl = when (Env.network) { + Network.TESTNET -> "https://mempool.space/testnet" + else -> "https://mempool.space" + } + val url = "$baseUrl/tx/${onchain.v1.txId}" + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + + return { context.startActivity(intent) } +} + +@Preview +@Composable +private fun PreviewLightning() { + AppThemeSurface { + ActivityExploreContent( + item = Activity.Lightning( + v1 = LightningActivity( + id = "test-lightning-1", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 50000UL, + fee = 1UL, + invoice = "lnbc...", + message = "Thanks for paying at the bar. Here's my share.", + timestamp = (System.currentTimeMillis() / 1000).toULong(), + preimage = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + createdAt = null, + updatedAt = null, + ), + ), + txDetails = null, + onCopy = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewOnchain() { + AppThemeSurface { + ActivityExploreContent( + item = Activity.Onchain( + v1 = OnchainActivity( + id = "test-onchain-1", + txType = PaymentType.RECEIVED, + txId = "abc123", + value = 100000UL, + fee = 500UL, + feeRate = 8UL, + address = "bc1...", + confirmed = true, + timestamp = (System.currentTimeMillis() / 1000 - 3600).toULong(), + isBoosted = false, + isTransfer = false, + doesExist = true, + confirmTimestamp = (System.currentTimeMillis() / 1000).toULong(), + channelId = null, + transferTxId = null, + createdAt = null, + updatedAt = null, + ), + ), + txDetails = null, + onCopy = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityItemScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityItemScreen.kt index 518e00d6a..a94e530b6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityItemScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityItemScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,11 +26,15 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.idValue import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime +import to.bitkit.models.Toast import to.bitkit.ui.Routes -import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon +import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.ButtonSize @@ -40,9 +43,14 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Title import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon +import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.viewmodels.ActivityListViewModel import uniffi.bitkitcore.Activity import uniffi.bitkitcore.LightningActivity @@ -54,21 +62,34 @@ import uniffi.bitkitcore.PaymentType fun ActivityItemScreen( viewModel: ActivityListViewModel, activityItem: Routes.ActivityItem, + onExploreClick: (String) -> Unit, onBackClick: () -> Unit, + onCloseClick: () -> Unit, ) { - val filteredActivities by viewModel.filteredActivities.collectAsState() - val item = filteredActivities?.find { - val id = when (it) { - is Activity.Onchain -> it.v1.id - is Activity.Lightning -> it.v1.id - } - id == activityItem.id - } ?: return + val activities by viewModel.filteredActivities.collectAsStateWithLifecycle() + val item = activities?.find { it.idValue == activityItem.id } + ?: return + + val app = appViewModel ?: return + val copyToastTitle = stringResource(R.string.common__copied) ScreenColumn { - // TODO update title based on txType - AppTopBar("Activity Details", onBackClick = onBackClick) - ActivityItemView(item) + AppTopBar( + titleText = stringResource(item.getScreenTitleRes()), + onBackClick = onBackClick, + actions = { CloseNavIcon(onClick = onCloseClick) }, + ) + ActivityItemView( + item = item, + onExploreClick = onExploreClick, + onCopy = { text -> + app.toast( + type = Toast.ToastType.SUCCESS, + title = copyToastTitle, + description = text.ellipsisMiddle(40) + ) + } + ) } } @@ -76,6 +97,8 @@ fun ActivityItemScreen( @Composable private fun ActivityItemView( item: Activity, + onExploreClick: (String) -> Unit, + onCopy: (String) -> Unit, ) { val isLightning = item is Activity.Lightning val accentColor = if (isLightning) Colors.Purple else Colors.Brand @@ -115,7 +138,7 @@ private fun ActivityItemView( verticalAlignment = Alignment.Bottom, modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(vertical = 16.dp) ) { BalanceHeaderView( sats = value.toLong(), @@ -260,7 +283,14 @@ private fun ActivityItemView( // Note section for Lightning payments with message if (item is Activity.Lightning && item.v1.message.isNotEmpty()) { - Column(modifier = Modifier.fillMaxWidth()) { + val message = item.v1.message + Column( + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(onClick = copyToClipboard(message) { + onCopy(message) + }) + ) { Caption13Up( text = stringResource(R.string.wallet__activity_invoice_note), color = Colors.White64, @@ -276,7 +306,7 @@ private fun ActivityItemView( .background(Colors.White10) ) { Title( - text = item.v1.message, + text = message, color = Colors.White, modifier = Modifier.padding(24.dp), ) @@ -348,7 +378,7 @@ private fun ActivityItemView( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { /* TODO: Implement explore functionality */ }, + onClick = { onExploreClick(item.idValue) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), @@ -487,7 +517,6 @@ private fun ZigzagDivider() { private fun PreviewLightningSent() { AppThemeSurface { Column { - // Lightning example ActivityItemView( item = Activity.Lightning( v1 = LightningActivity( @@ -503,7 +532,9 @@ private fun PreviewLightningSent() { createdAt = null, updatedAt = null, ) - ) + ), + onExploreClick = {}, + onCopy = {}, ) } } @@ -514,7 +545,6 @@ private fun PreviewLightningSent() { private fun PreviewOnchain() { AppThemeSurface { Column { - // Onchain example ActivityItemView( item = Activity.Onchain( v1 = OnchainActivity( @@ -536,7 +566,9 @@ private fun PreviewOnchain() { createdAt = null, updatedAt = null, ) - ) + ), + onExploreClick = {}, + onCopy = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index 955367155..c3db0073b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -127,6 +127,9 @@ fun ActivityListWithHeaders( ) } } + item { + Spacer(modifier = Modifier.height(120.dp)) + } } } else { EmptyActivityRow(onClick = onEmptyActivityRowClick) @@ -135,7 +138,7 @@ fun ActivityListWithHeaders( } @Composable -fun ActivityList( +fun HomeActivityList( items: List?, onAllActivityClick: () -> Unit, onActivityItemClick: (String) -> Unit, @@ -247,7 +250,7 @@ private fun PreviewActivityListWithHeadersView() { @Composable private fun PreviewActivityListItems() { AppThemeSurface { - ActivityList( + HomeActivityList( testActivityItems, onAllActivityClick = {}, onActivityItemClick = {}, @@ -260,7 +263,7 @@ private fun PreviewActivityListItems() { @Composable private fun PreviewActivityListEmpty() { AppThemeSurface { - ActivityList( + HomeActivityList( items = emptyList(), onAllActivityClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index e7c70da50..7072a7163 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ext.DatePattern import to.bitkit.ext.formatted +import to.bitkit.ext.idValue import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyMSB @@ -39,11 +40,6 @@ fun ActivityRow( item: Activity, onClick: (String) -> Unit, ) { - val id = when (item) { - is Activity.Onchain -> item.v1.id - is Activity.Lightning -> item.v1.id - } - val status: PaymentState? = when (item) { is Activity.Lightning -> item.v1.status is Activity.Onchain -> null @@ -67,7 +63,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(id) } + .clickableAlpha { onClick(item.idValue) } .padding(vertical = 16.dp) ) { ActivityIcon(activity = item, size = 32.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt index f7f800291..c58029864 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAddressScreen.kt @@ -52,7 +52,7 @@ fun SendAddressScreen( Caption13Up(text = stringResource(R.string.wallet__send_to)) Spacer(modifier = Modifier.height(16.dp)) TextField( - placeholder = { Text(stringResource(R.string.address_placeholder)) }, + placeholder = { Text(stringResource(R.string.wallet__send_address_placeholder)) }, value = uiState.addressInput, onValueChange = { onEvent(SendEvent.AddressChange(it)) }, minLines = 12, diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index dc189fd74..1634987d9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -74,7 +74,7 @@ fun BlocktankRegtestScreen( InfoField( value = Env.blocktankBaseUrl, - label = stringResource(R.string.address), + label = stringResource(R.string.wallet__activity_address), ) Text( text = "These actions are executed on the staging Blocktank server node.", diff --git a/app/src/main/java/to/bitkit/ui/shared/CopyToClipboardButton.kt b/app/src/main/java/to/bitkit/ui/shared/CopyToClipboardButton.kt index 354ac7be6..7fa31618d 100644 --- a/app/src/main/java/to/bitkit/ui/shared/CopyToClipboardButton.kt +++ b/app/src/main/java/to/bitkit/ui/shared/CopyToClipboardButton.kt @@ -1,20 +1,34 @@ package to.bitkit.ui.shared +import android.content.ClipData import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import to.bitkit.R @Composable -internal fun CopyToClipboardButton(text: String) { - val clipboard = LocalClipboardManager.current - IconButton(onClick = { clipboard.setText(AnnotatedString((text))) }) { +fun CopyToClipboardButton(text: String) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val label = stringResource(R.string.app_name) + IconButton( + onClick = { + scope.launch { + val clipData = ClipData.newPlainText(label, text) + clipboard.setClipEntry(ClipEntry(clipData)) + } + } + ) { Icon( imageVector = Icons.Default.ContentCopy, contentDescription = null, diff --git a/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt b/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt new file mode 100644 index 000000000..c53f0853d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/ActivityItems.kt @@ -0,0 +1,27 @@ +package to.bitkit.ui.utils + +import to.bitkit.R +import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.PaymentType + +fun Activity.getScreenTitleRes(): Int { + val isSent = when (this) { + is Activity.Lightning -> v1.txType == PaymentType.SENT + is Activity.Onchain -> v1.txType == PaymentType.SENT + } + + var resId = when { + isSent -> R.string.wallet__activity_bitcoin_sent + else -> R.string.wallet__activity_bitcoin_received + } + + val isTransfer = this is Activity.Onchain && v1.isTransfer + if (isTransfer) { + resId = when { + isSent -> R.string.wallet__activity_transfer_spending_done + else -> R.string.wallet__activity_transfer_savings_done + } + } + + return resId +} diff --git a/app/src/main/java/to/bitkit/ui/utils/Clipboard.kt b/app/src/main/java/to/bitkit/ui/utils/Clipboard.kt new file mode 100644 index 000000000..fea2a2396 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/Clipboard.kt @@ -0,0 +1,32 @@ +package to.bitkit.ui.utils + +import android.content.ClipData +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import to.bitkit.R + +@Composable +fun copyToClipboard( + text: String, + label: String = stringResource(R.string.app_name), + block: (() -> Unit)? = null, +): () -> Unit { + val clipboard = LocalClipboard.current + val haptic = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + return { + scope.launch { + val clipData = ClipData.newPlainText(label, text) + clipboard.setClipEntry(ClipEntry(clipData)) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + block?.invoke() + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/Text.kt b/app/src/main/java/to/bitkit/ui/utils/Text.kt index 811267de3..da8623558 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Text.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Text.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.utils import androidx.annotation.StringRes +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @@ -12,6 +13,9 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import java.math.BigDecimal import java.text.DecimalFormat @@ -127,7 +131,7 @@ fun localizedRandom(@StringRes id: Int): String { } } -fun BigDecimal.formatCurrency() : String? { +fun BigDecimal.formatCurrency(): String? { val symbols = DecimalFormatSymbols(Locale.getDefault()).apply { decimalSeparator = '.' groupingSeparator = ',' @@ -140,4 +144,36 @@ fun BigDecimal.formatCurrency() : String? { return runCatching { formatter.format(this) }.getOrNull() } +/** + * Pluralizes a string by resId using the ICU MessageFormat with the provided arguments map. + * + * @param id The string resource ID to be localized and pluralized. + * @param argMap A map of arguments to be formatted into the localized string for pluralization. + * @return A localized string with the appropriate pluralization and formatted arguments. + * + * Example: + * ``` + * localizedPlural(R.string.settings__addr__spend_number, mapOf("fundsToSpend" to "1234", "count" to 2)) + * ``` + */ +@Suppress("SpellCheckingInspection") +@Composable +fun localizedPlural(@StringRes id: Int, argMap: Map): String { + val resources = LocalContext.current.resources + + return remember(id, argMap) { + val pattern = resources.getString(id) + val messageFormat = android.icu.text.MessageFormat(pattern) + return@remember messageFormat.format(argMap) + } +} +@Preview +@Composable +private fun PreviewLocalizedPlural() { + AppThemeSurface { + Text( + localizedPlural(R.string.settings__addr__spend_number, mapOf("fundsToSpend" to "1234", "count" to 2)) + ) + } +} diff --git a/app/src/main/java/to/bitkit/utils/AddressChecker.kt b/app/src/main/java/to/bitkit/utils/AddressChecker.kt index ec3532469..d1f87fc5e 100644 --- a/app/src/main/java/to/bitkit/utils/AddressChecker.kt +++ b/app/src/main/java/to/bitkit/utils/AddressChecker.kt @@ -11,27 +11,6 @@ import uniffi.bitkitcore.ActivityException.SerializationException import javax.inject.Inject import javax.inject.Singleton -@Serializable -data class AddressStats( - val funded_txo_count: Int, - val funded_txo_sum: Int, - val spent_txo_count: Int, - val spent_txo_sum: Int, - val tx_count: Int, -) - -@Serializable -data class AddressInfo( - val address: String, - val chain_stats: AddressStats, - val mempool_stats: AddressStats, -) - -sealed class AddressCheckerError(message: String? = null) : AppError(message) { - data class NetworkError(val error: Throwable) : AddressCheckerError(error.message) - data object InvalidResponse : AddressCheckerError() -} - /** * TEMPORARY IMPLEMENTATION * This is a short-term solution for getting address information using electrs. @@ -57,4 +36,74 @@ class AddressChecker @Inject constructor( throw AddressCheckerError.NetworkError(e) } } + + suspend fun getTransaction(txid: String): TxDetails { + try { + val response = client.get("${Env.esploraServerUrl}/tx/$txid") + if (!response.status.isSuccess()) { + throw AddressCheckerError.InvalidResponse + } + return response.body() + } catch (_: ClientRequestException) { + throw AddressCheckerError.InvalidResponse + } catch (_: SerializationException) { + throw AddressCheckerError.InvalidResponse + } catch (e: Exception) { + throw AddressCheckerError.NetworkError(e) + } + } +} + +@Suppress("PropertyName") +@Serializable +data class AddressStats( + val funded_txo_count: Int, + val funded_txo_sum: Int, + val spent_txo_count: Int, + val spent_txo_sum: Int, + val tx_count: Int, +) + +@Suppress("PropertyName") +@Serializable +data class AddressInfo( + val address: String, + val chain_stats: AddressStats, + val mempool_stats: AddressStats, +) + +@Suppress("SpellCheckingInspection", "PropertyName") +@Serializable +data class TxInput( + val txid: String? = null, + val vout: Int? = null, + val prevout: TxOutput? = null, + val scriptsig: String? = null, + val scriptsig_asm: String? = null, + val witness: List? = null, + val is_coinbase: Boolean? = null, + val sequence: Long? = null, +) + +@Suppress("SpellCheckingInspection", "PropertyName") +@Serializable +data class TxOutput( + val scriptpubkey: String, + val scriptpubkey_asm: String? = null, + val scriptpubkey_type: String? = null, + val scriptpubkey_address: String? = null, + val value: Long, + val n: Int? = null, +) + +@Serializable +data class TxDetails( + val txid: String, + val vin: List, + val vout: List, +) + +sealed class AddressCheckerError(message: String? = null) : AppError(message) { + data class NetworkError(val error: Throwable) : AddressCheckerError(error.message) + data object InvalidResponse : AddressCheckerError() } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt new file mode 100644 index 000000000..af185ae03 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -0,0 +1,40 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import to.bitkit.utils.AddressChecker +import to.bitkit.utils.Logger +import to.bitkit.utils.TxDetails +import javax.inject.Inject + +@HiltViewModel +class ActivityDetailViewModel @Inject constructor( + private val addressChecker: AddressChecker, +) : ViewModel() { + private val _txDetails = MutableStateFlow(null) + val txDetails = _txDetails.asStateFlow() + + fun fetchTransactionDetails(txid: String) { + viewModelScope.launch { + try { + // TODO replace with bitkit-core method when available + _txDetails.value = addressChecker.getTransaction(txid) + } catch (e: Throwable) { + Logger.error("fetchTransactionDetails error", e, context = TAG) + _txDetails.value = null + } + } + } + + fun clearTransactionDetails() { + _txDetails.value = null + } + + private companion object { + const val TAG = "ActivityDetailViewModel" + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3243b62a8..7f0642579 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -29,6 +29,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.WatchResult +import to.bitkit.ext.idValue import to.bitkit.ext.removeSpaces import to.bitkit.ext.watchUntil import to.bitkit.models.NewTransactionSheetDetails @@ -635,12 +636,7 @@ class AppViewModel @Inject constructor( return@launch } - val id = when(activity) { - is Activity.Lightning -> activity.v1.id - is Activity.Onchain -> activity.v1.id - } - - mainScreenEffect(MainScreenEffect.NavigateActivityDetail(id)) + mainScreenEffect(MainScreenEffect.NavigateActivityDetail(activity.idValue)) } } diff --git a/app/src/main/res/values/strings_app.xml b/app/src/main/res/values/strings_app.xml index 3e6ae371f..fb8dab4f6 100644 --- a/app/src/main/res/values/strings_app.xml +++ b/app/src/main/res/values/strings_app.xml @@ -1,10 +1,5 @@ - Add - Address - Enter a Bitcoin address or a Lighting invoice - All Activity - Enter Bitcoin Amount Bitkit Main Notification Channel for generic notifications. channel_app diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e9fb3504..bbbe18ff3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ barcodeScanning = "17.3.0" biometric = "1.4.0-alpha02" bouncyCastle = "1.79" camera = "1.4.2" -composeBom = "2025.03.01" # https://developer.android.com/develop/ui/compose/bom/bom-mapping +composeBom = "2025.05.00" # https://developer.android.com/develop/ui/compose/bom/bom-mapping constraintlayoutCompose = "1.1.1" coreKtx = "1.15.0" coreSplashscreen = "1.0.1"