diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index a65a4edf0..636154623 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -9,7 +9,6 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import to.bitkit.env.Env -import to.bitkit.utils.ResourceProvider import javax.inject.Inject @HiltAndroidApp @@ -24,7 +23,6 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() - ResourceProvider.init(this) currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } Env.initAppStoragePath(filesDir.absolutePath) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 4b10ceb7c..7b4d198eb 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -105,17 +105,5 @@ internal object Env { nodeId = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc", address = "34.65.86.104:9400", ) - val btStagingOld = LnPeer( - nodeId = "03b9a456fb45d5ac98c02040d39aec77fa3eeb41fd22cf40b862b393bcfc43473a", - address = "35.233.47.252:9400", - ) - val polarToRegtest = LnPeer( - nodeId = "023f6e310ff049d68c64a0eb97440b998aa15fd99162317d6743d7023519862e23", - address = "10.0.2.2:9735", - ) - val local = LnPeer( - nodeId = "02faf2d1f5dc153e8931d8444c4439e46a81cb7eeadba8562e7fec3690c261ce87", - address = "10.0.2.2:9738", - ) } } diff --git a/app/src/main/java/to/bitkit/ext/Flows.kt b/app/src/main/java/to/bitkit/ext/Flows.kt new file mode 100644 index 000000000..3090d0fc8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Flows.kt @@ -0,0 +1,41 @@ +package to.bitkit.ext + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile + +/** + * Suspends and collects the elements of the Flow until the provided predicate satisfies + * a `WatchResult.Complete`. + * + * @param predicate A suspending function that processes each emitted value and returns a + * `WatchResult` indicating whether to continue or complete with a result. + * @return The result of type `R` when the `WatchResult.Complete` is returned by the predicate. + */ +suspend inline fun Flow.watchUntil( + crossinline predicate: suspend (T) -> WatchResult, +): R { + val result = CompletableDeferred() + + this.takeWhile { value -> + when (val eventResult = predicate(value)) { + is WatchResult.Continue -> { + eventResult.result?.let { result.complete(it) } + true + } + + is WatchResult.Complete -> { + result.complete(eventResult.result) + false + } + } + }.collect() + + return result.await() +} + +sealed interface WatchResult { + data class Continue(val result: T? = null) : WatchResult + data class Complete(val result: T) : WatchResult +} diff --git a/app/src/main/java/to/bitkit/models/LnPeer.kt b/app/src/main/java/to/bitkit/models/LnPeer.kt index e6abb1ca3..e4fe50a5c 100644 --- a/app/src/main/java/to/bitkit/models/LnPeer.kt +++ b/app/src/main/java/to/bitkit/models/LnPeer.kt @@ -17,6 +17,7 @@ data class LnPeer( ) val address get() = "$host:$port" + override fun toString() = "$nodeId@${address}" companion object { @@ -24,5 +25,31 @@ data class LnPeer( nodeId = nodeId, address = address, ) + + fun parseUri(string: String): Result { + val uri = string.split("@") + val nodeId = uri[0] + + if (uri.size != 2) { + return Result.failure(Exception("Invalid peer uri")) + } + + val address = uri[1].split(":") + + if (address.size < 2) { + return Result.failure(Exception("Invalid peer uri")) + } + + val ip = address[0] + val port = address[1] + + return Result.success( + LnPeer( + nodeId = nodeId, + host = ip, + port = port, + ) + ) + } } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index f3e1828af..6aa8672db 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -26,6 +26,7 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid +import org.lightningdevkit.ldknode.UserChannelId import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue @@ -227,6 +228,26 @@ class LightningService @Inject constructor( } } + suspend fun connectPeer(peer: LnPeer): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup + + return ServiceQueue.LDK.background { + try { + Logger.debug("Connecting peer: $peer") + + node.connect(peer.nodeId, peer.address, persist = true) + + Logger.info("Peer connected: $peer") + + Result.success(Unit) + } catch (e: NodeException) { + val error = LdkError(e) + Logger.error("Peer connect error: $peer", error) + Result.failure(error) + } + } + } + suspend fun disconnectPeer(peer: LnPeer) { val node = this.node ?: throw ServiceError.NodeNotSetup Logger.debug("Disconnecting peer: $peer") @@ -238,25 +259,36 @@ class LightningService @Inject constructor( } catch (e: NodeException) { Logger.warn("Peer disconnect error: $peer", LdkError(e)) } - } - // endregion + } // endregion // region channels - suspend fun openChannel(peer: LnPeer, channelAmountSats: ULong, pushToCounterpartySats: ULong? = null) { - val node = this@LightningService.node ?: throw ServiceError.NodeNotSetup + suspend fun openChannel( + peer: LnPeer, + channelAmountSats: ULong, + pushToCounterpartySats: ULong? = null, + ) : Result { + val node = this.node ?: throw ServiceError.NodeNotSetup - try { - ServiceQueue.LDK.background { - node.openChannel( + return ServiceQueue.LDK.background { + try { + Logger.debug("Initiating channel open (sats: $channelAmountSats) with peer: $peer") + + val userChannelId = node.openChannel( nodeId = peer.nodeId, address = peer.address, channelAmountSats = channelAmountSats, pushToCounterpartyMsat = pushToCounterpartySats?.millis, channelConfig = null, ) + + Logger.info("Channel open initiated, userChannelId: $userChannelId") + + Result.success(userChannelId) + } catch (e: NodeException) { + val error = LdkError(e) + Logger.error("Error initiating channel open", error) + Result.failure(error) } - } catch (e: NodeException) { - throw LdkError(e) } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 5df1931c6..77c69c606 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -25,6 +25,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions import androidx.navigation.toRoute import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -51,7 +52,11 @@ import to.bitkit.ui.screens.transfer.SpendingAmountScreen import to.bitkit.ui.screens.transfer.SpendingConfirmScreen import to.bitkit.ui.screens.transfer.SpendingIntroScreen import to.bitkit.ui.screens.transfer.TransferIntroScreen +import to.bitkit.ui.screens.transfer.external.ExternalAmountScreen +import to.bitkit.ui.screens.transfer.external.ExternalConfirmScreen import to.bitkit.ui.screens.transfer.external.ExternalConnectionScreen +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.AllActivityScreen @@ -73,6 +78,7 @@ import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.BlocktankViewModel import to.bitkit.viewmodels.CurrencyViewModel +import to.bitkit.viewmodels.ExternalNodeViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -232,13 +238,13 @@ fun ContentView( appViewModel.setHasSeenSavingsIntro(true) }, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, ) } composable { SavingsAvailabilityScreen( onBackClick = { navController.popBackStack() }, - onCancelClick = { navController.popBackStack(inclusive = false) }, + onCancelClick = { navController.navigateToHome() }, onContinueClick = { navController.navigate(Routes.SavingsConfirm) }, ) } @@ -247,20 +253,20 @@ fun ContentView( onConfirm = { navController.navigate(Routes.SavingsProgress) }, onAdvancedClick = { navController.navigate(Routes.SavingsAdvanced) }, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, ) } composable { SavingsAdvancedScreen( onContinueClick = { navController.popBackStack(inclusive = false) }, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, ) } composable { SavingsProgressScreen( - onContinueClick = { navController.popBackStack(inclusive = false) }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onContinueClick = { navController.navigateToHome() }, + onCloseClick = { navController.navigateToHome() }, ) } composable { @@ -270,14 +276,14 @@ fun ContentView( appViewModel.setHasSeenSpendingIntro(true) }, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, ) } composable { SpendingAmountScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, onOrderCreated = { navController.navigate(Routes.SpendingConfirm) }, ) } @@ -285,7 +291,7 @@ fun ContentView( SpendingConfirmScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, onLearnMoreClick = { navController.navigate(Routes.TransferLiquidity) }, onAdvancedClick = { navController.navigate(Routes.SpendingAdvanced) }, onConfirm = { navController.navigate(Routes.SettingUp) }, @@ -295,22 +301,22 @@ fun ContentView( SpendingAdvancedScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, onOrderCreated = { navController.popBackStack(inclusive = false) }, ) } composable { LiquidityScreen( onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, onContinueClick = { navController.popBackStack() } ) } composable { SettingUpScreen( viewModel = transferViewModel, - onCloseClick = { navController.popBackStack(inclusive = false) }, - onContinueClick = { navController.popBackStack(inclusive = false) }, + onCloseClick = { navController.navigateToHome() }, + onContinueClick = { navController.navigateToHome() }, ) } composable { @@ -322,11 +328,11 @@ fun ContentView( } else { navController.navigateToTransferSpendingAmount() } - }, + }, onFund = { scope.launch { // TODO show receive sheet -> ReceiveAmount - navController.popBackStack(inclusive = false) + navController.navigateToHome() delay(500) // Wait for nav to actually finish appViewModel.showSheet(BottomSheetType.Receive) } @@ -339,16 +345,63 @@ fun ContentView( composable { FundingAdvancedScreen( onLnUrl = { navController.navigateToQrScanner() }, - onManual = { navController.navigate(Routes.ExternalConnection) }, + onManual = { navController.navigate(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.popBackStack(inclusive = true) }, ) } - composable { - ExternalConnectionScreen( - onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.popBackStack(inclusive = true) }, - ) + navigation( + startDestination = Routes.ExternalConnection, + ) { + composable { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ExternalNav) } + val viewModel = hiltViewModel(parentEntry) + + ExternalConnectionScreen( + viewModel = viewModel, + onNodeConnected = { navController.navigate(Routes.ExternalAmount) }, + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.popBackStack(inclusive = true) }, + ) + } + composable { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ExternalNav) } + val viewModel = hiltViewModel(parentEntry) + + ExternalAmountScreen( + viewModel = viewModel, + onContinue = { navController.navigate(Routes.ExternalConfirm) }, + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.popBackStack(inclusive = true) }, + ) + } + composable { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ExternalNav) } + val viewModel = hiltViewModel(parentEntry) + + ExternalConfirmScreen( + viewModel = viewModel, + onConfirm = { + walletViewModel.refreshState() + navController.navigate(Routes.ExternalSuccess) + }, + onNetworkFeeClick = { navController.navigate(Routes.ExternalFeeCustom) }, + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.popBackStack(inclusive = true) }, + ) + } + composable { + ExternalSuccessScreen( + onContinue = { navController.navigateToHome() }, + onClose = { navController.navigateToHome() }, + ) + } + composable { + ExternalFeeCustomScreen( + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.popBackStack(inclusive = true) }, + ) + } } } } @@ -554,6 +607,11 @@ private fun NavGraphBuilder.qrScanner( // endregion // region events +fun NavController.navigateToHome() = navigate( + route = Routes.Home, + navOptions = navOptions { popUpTo(Routes.Home) } +) + fun NavController.navigateToSettings() = navigate( route = Routes.Settings, ) @@ -734,9 +792,24 @@ object Routes { @Serializable data object FundingAdvanced + @Serializable + data object ExternalNav + @Serializable data object ExternalConnection + @Serializable + data object ExternalAmount + + @Serializable + data object ExternalConfirm + + @Serializable + data object ExternalSuccess + + @Serializable + data object ExternalFeeCustom + @Serializable data object AllActivity diff --git a/app/src/main/java/to/bitkit/ui/NodeStateScreen.kt b/app/src/main/java/to/bitkit/ui/NodeStateScreen.kt index 7a6dd5b2c..17357f8ac 100644 --- a/app/src/main/java/to/bitkit/ui/NodeStateScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeStateScreen.kt @@ -141,10 +141,10 @@ fun NodeStateScreen( ) Peers(uiState.peers, viewModel::disconnectPeer) Channels( - uiState.channels, - uiState.peers.isNotEmpty(), - viewModel::openChannel, - viewModel::closeChannel, + channels = uiState.channels, + hasPeers = uiState.peers.isNotEmpty(), + onChannelOpenTap = viewModel::openChannel, + onChannelCloseTap = viewModel::closeChannel, ) uiState.balanceDetails?.let { Balances(it) diff --git a/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt b/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt new file mode 100644 index 000000000..a939712a7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt @@ -0,0 +1,36 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import to.bitkit.ui.theme.Colors + +@Composable +fun RowScope.FeeInfo( + label: String, + amount: Long, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .weight(1f) + .padding(top = 16.dp) + ) { + Caption13Up( + text = label, + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(8.dp)) + MoneySSB(sats = amount) + Spacer(modifier = Modifier.weight(1f)) + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + } +} diff --git a/app/src/main/java/to/bitkit/ui/components/InfoScreenContent.kt b/app/src/main/java/to/bitkit/ui/components/InfoScreenContent.kt new file mode 100644 index 000000000..5115c12ff --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/InfoScreenContent.kt @@ -0,0 +1,113 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InfoScreenContent( + navTitle: String, + title: AnnotatedString, + description: AnnotatedString, + image: Painter, + showCloseButton: Boolean = true, + buttonText: String, + onButtonClick: () -> Unit, + onCloseClick: () -> Unit, +) { + ScreenColumn { + CenterAlignedTopAppBar( + title = { Title(text = navTitle) }, + actions = { + if (showCloseButton) { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.common__close), + ) + } + } + }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Display(text = title) + Spacer(modifier = Modifier.height(8.dp)) + BodyM(text = description, color = Colors.White64) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) { + Image( + painter = image, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(256.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + text = buttonText, + onClick = onButtonClick, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + InfoScreenContent( + navTitle = stringResource(R.string.lightning__transfer__nav_title), + title = stringResource(R.string.lightning__savings_interrupted__title).withAccent(), + description = stringResource(R.string.lightning__savings_interrupted__text) + .withAccent(accentStyle = SpanStyle(color = Colors.White, fontWeight = FontWeight.Bold)), + image = painterResource(R.drawable.check), + buttonText = stringResource(R.string.common__ok), + onButtonClick = {}, + onCloseClick = {}, + ) + } +} 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 d9273be2f..308a1d60d 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -364,7 +364,7 @@ fun Caption13Up( fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 18.sp, - letterSpacing = 0.4.sp, + letterSpacing = 0.8.sp, fontFamily = InterFontFamily, color = color, textAlign = TextAlign.Start, diff --git a/app/src/main/java/to/bitkit/ui/components/TransferAmount.kt b/app/src/main/java/to/bitkit/ui/components/TransferAmount.kt index 7f7a017bc..527220db4 100644 --- a/app/src/main/java/to/bitkit/ui/components/TransferAmount.kt +++ b/app/src/main/java/to/bitkit/ui/components/TransferAmount.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -35,6 +36,12 @@ fun TransferAmount( overrideSats: Long? = null, onSatsChange: (Long) -> Unit, ) { + val isPreview = LocalInspectionMode.current + if (isPreview) { + MoneyDisplay(defaultValue) + return + } + val currency = currencyViewModel ?: return var satsAmount by rememberSaveable(stateSaver = TextFieldValue.Saver) { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index fbb70bde9..e6c3d3153 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -16,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -36,11 +33,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo import to.bitkit.ui.components.LightningChannel -import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SwipeToConfirm import to.bitkit.ui.scaffold.AppTopBar @@ -81,7 +77,6 @@ fun SpendingConfirmScreen( .padding(horizontal = 16.dp) .fillMaxSize() .verticalScroll(rememberScrollState()) - ) { val clientBalance = order.clientBalanceSat val networkFee = order.networkFeeSat @@ -92,6 +87,7 @@ fun SpendingConfirmScreen( Spacer(modifier = Modifier.height(32.dp)) Display(text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) Spacer(modifier = Modifier.height(8.dp)) + Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.height(IntrinsicSize.Min) @@ -185,26 +181,3 @@ fun SpendingConfirmScreen( } } } - -@Composable -private fun RowScope.FeeInfo( - label: String, - amount: Long, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxHeight() - .weight(1f) - .padding(top = 16.dp) - ) { - Caption13Up( - text = label, - color = Colors.White64, - ) - Spacer(modifier = Modifier.height(8.dp)) - MoneySSB(sats = amount) - Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt new file mode 100644 index 000000000..d9b744d21 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt @@ -0,0 +1,163 @@ +package to.bitkit.ui.screens.transfer.external + +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.LocalBalances +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.TransferAmount +import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.ExternalNodeViewModel +import kotlin.math.roundToLong + +@Composable +fun ExternalAmountScreen( + viewModel: ExternalNodeViewModel, + onContinue: () -> Unit, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, +) { + ExternalAmountContent( + onContinueClick = { satsAmount -> + viewModel.onAmountContinue(satsAmount) + onContinue() + }, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) +} + +@Composable +private fun ExternalAmountContent( + onContinueClick: (Long) -> Unit = {}, + onBackClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__external__nav_title), + onBackClick = onBackClick, + actions = { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.common__close), + ) + } + }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .imePadding() + ) { + var satsAmount by rememberSaveable { mutableLongStateOf(0) } + var overrideSats: Long? by remember { mutableStateOf(null) } + + val availableAmount = LocalBalances.current.totalOnchainSats + + Spacer(modifier = Modifier.height(16.dp)) + Display(stringResource(R.string.lightning__external_amount__title).withAccent(accentColor = Colors.Purple)) + Spacer(modifier = Modifier.height(32.dp)) + + TransferAmount( + primaryDisplay = LocalCurrencies.current.primaryDisplay, + overrideSats = overrideSats, + ) { sats -> + satsAmount = sats + overrideSats = null + } + + Spacer(modifier = Modifier.weight(1f)) + + // Actions Row + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + Column { + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(8.dp)) + MoneySSB(sats = availableAmount.toLong()) + } + Spacer(modifier = Modifier.weight(1f)) + UnitButton(color = Colors.Purple) + // 25% Button + NumberPadActionButton( + text = stringResource(R.string.lightning__spending_amount__quarter), + color = Colors.Purple, + onClick = { + val quarter = (availableAmount.toDouble() / 4.0).roundToLong() + overrideSats = quarter + }, + ) + // Max Button + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Purple, + onClick = { + val max = (availableAmount.toDouble() * 0.9).toLong() // TODO calc max amount + overrideSats = max + }, + ) + } + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { onContinueClick(satsAmount) }, + enabled = satsAmount != 0L, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + ExternalAmountContent( + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt new file mode 100644 index 000000000..577ef0e6c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConfirmScreen.kt @@ -0,0 +1,195 @@ +package to.bitkit.ui.screens.transfer.external + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +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 to.bitkit.R +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.util.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.ExternalNodeContract +import to.bitkit.viewmodels.ExternalNodeContract.SideEffect +import to.bitkit.viewmodels.ExternalNodeViewModel + +@Composable +fun ExternalConfirmScreen( + viewModel: ExternalNodeViewModel, + onConfirm: () -> Unit, + onNetworkFeeClick: () -> Unit, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(viewModel, onConfirm) { + viewModel.effects.collect { + when (it) { + SideEffect.ConfirmSuccess -> onConfirm() + else -> Unit + } + } + } + + ExternalConfirmContent( + uiState = uiState, + onConfirm = { viewModel.onConfirm() }, + onNetworkFeeClick = onNetworkFeeClick, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) +} + +@Composable +private fun ExternalConfirmContent( + uiState: ExternalNodeContract.UiState, + onConfirm: () -> Unit = {}, + onNetworkFeeClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__external__nav_title), + onBackClick = onBackClick, + actions = { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.common__close), + ) + } + }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + val networkFee = 0L // TODO calculate txFee + val serviceFee = 0L + val totalFee = uiState.localBalance + networkFee + + Spacer(modifier = Modifier.height(16.dp)) + Display(text = stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(top = 16.dp) + .clickableAlpha(onNetworkFeeClick) + ) { + Caption13Up( + text = stringResource(R.string.lightning__spending_confirm__network_fee), + color = Colors.White64, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + MoneySSB(sats = networkFee) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painterResource(R.drawable.ic_pencil_simple), + contentDescription = null, + tint = Colors.White, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.weight(1f)) + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + } + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + amount = serviceFee, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__amount), + amount = uiState.localBalance, + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__total), + amount = totalFee, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(id = R.drawable.coin_stack_x), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .size(256.dp) + .align(alignment = CenterHorizontally) + ) + + SwipeToConfirm( + text = stringResource(R.string.lightning__transfer__swipe), + loading = uiState.isLoading, + confirmed = uiState.isLoading, + color = Colors.Purple, + onConfirm = onConfirm, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun ExternalConfirmScreenPreview() { + AppThemeSurface { + ExternalConfirmContent( + uiState = ExternalNodeContract.UiState( + localBalance = 45_500L, + ) + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt index eaa03f7b2..75cb718b0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalConnectionScreen.kt @@ -1,27 +1,105 @@ package to.bitkit.ui.screens.transfer.external -import androidx.compose.foundation.layout.Box +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import to.bitkit.R +import to.bitkit.models.LnPeer +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppShapes +import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.ExternalNodeContract +import to.bitkit.viewmodels.ExternalNodeContract.SideEffect +import to.bitkit.viewmodels.ExternalNodeViewModel @Composable fun ExternalConnectionScreen( + viewModel: ExternalNodeViewModel, + onNodeConnected: () -> Unit, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, +) { + val app = appViewModel ?: return + val clipboard = LocalClipboardManager.current + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(viewModel, onNodeConnected) { + viewModel.effects.collect { + when (it) { + SideEffect.ConnectionSuccess -> onNodeConnected() + else -> Unit + } + } + } + + ExternalConnectionContent( + uiState = uiState, + onContinueClick = { peer -> viewModel.onConnectionContinue(peer) }, + onScanClick = { app.toast(Exception("Coming soon")) }, + onPasteClick = { viewModel.onConnectionPaste(clipboardText = clipboard.getText()?.text.orEmpty()) }, + onBackClick = onBackClick, + onCloseClick = onCloseClick, + ) +} + +@Composable +private fun ExternalConnectionContent( + uiState: ExternalNodeContract.UiState, + onContinueClick: (LnPeer) -> Unit = {}, + onScanClick: () -> Unit = {}, + onPasteClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, ) { + var nodeId by remember(uiState.peer) { mutableStateOf(uiState.peer?.nodeId.orEmpty()) } + var host by remember(uiState.peer) { mutableStateOf(uiState.peer?.host.orEmpty()) } + var port by remember(uiState.peer) { mutableStateOf(uiState.peer?.port.orEmpty()) } + + val isValid = nodeId.length == 66 && host.isNotBlank() && port.isNotBlank() + ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__external__nav_title), @@ -35,16 +113,120 @@ fun ExternalConnectionScreen( } }, ) - Box(modifier = Modifier.fillMaxSize()) { - Text("TODO: External Connection Screen", modifier = Modifier.align(Alignment.Center)) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Display(stringResource(R.string.lightning__external_manual__title).withAccent(accentColor = Colors.Purple)) + Spacer(modifier = Modifier.height(8.dp)) + BodyM(stringResource(R.string.lightning__external_manual__text), color = Colors.White64) + Spacer(modifier = Modifier.height(16.dp)) + + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.lightning__external_manual__node_id), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + placeholder = { Text("00000000000000000000000000000000000000000000000000000000000000") }, + value = nodeId, + onValueChange = { nodeId = it }, + singleLine = false, + colors = AppTextFieldDefaults.semiTransparent, + shape = AppShapes.smallInput, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.None, + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.lightning__external_manual__host), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + placeholder = { Text("00.00.00.00") }, + value = host, + onValueChange = { host = it }, + singleLine = true, + colors = AppTextFieldDefaults.semiTransparent, + shape = AppShapes.smallInput, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.None, + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + Caption13Up(text = stringResource(R.string.lightning__external_manual__port), color = Colors.White64) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + placeholder = { Text("9735") }, + value = port, + onValueChange = { port = it }, + singleLine = true, + colors = AppTextFieldDefaults.semiTransparent, + shape = AppShapes.smallInput, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.None, + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + PrimaryButton( + text = stringResource(R.string.lightning__external_manual__paste), + size = ButtonSize.Small, + onClick = onPasteClick, + icon = { + Icon( + painter = painterResource(R.drawable.ic_clipboard_text_simple), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + fullWidth = false, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.lightning__external_manual__scan), + onClick = onScanClick, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { onContinueClick(LnPeer(nodeId = nodeId, host = host, port = port)) }, + enabled = isValid, + isLoading = uiState.isLoading, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(16.dp)) } } } @Preview(showSystemUi = true, showBackground = true) @Composable -private fun ExternalConnectionScreenPreview() { +private fun Preview() { AppThemeSurface { - ExternalConnectionScreen() + ExternalConnectionContent( + uiState = ExternalNodeContract.UiState(), + ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt new file mode 100644 index 000000000..40eea6a18 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt @@ -0,0 +1,50 @@ +package to.bitkit.ui.screens.transfer.external + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.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.tooling.preview.Preview +import to.bitkit.R +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface + +@Composable +fun ExternalFeeCustomScreen( + onBackClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__external__nav_title), + onBackClick = onBackClick, + actions = { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.common__close), + ) + } + }, + ) + Box(modifier = Modifier.fillMaxSize()) { + Text("TODO: ExternalFeeCustomScreen", modifier = Modifier.align(Alignment.Center)) + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun ExternalFeeCustomScreenPreview() { + AppThemeSurface { + ExternalFeeCustomScreen() + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalSuccessScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalSuccessScreen.kt new file mode 100644 index 000000000..8dbe36b67 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalSuccessScreen.kt @@ -0,0 +1,42 @@ +package to.bitkit.ui.screens.transfer.external + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R +import to.bitkit.ui.components.InfoScreenContent +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.localizedRandom +import to.bitkit.ui.utils.withAccent + +@Composable +fun ExternalSuccessScreen( + onContinue: () -> Unit, + onClose: () -> Unit, +) { + InfoScreenContent( + navTitle = stringResource(R.string.lightning__external__nav_title), + title = stringResource(R.string.lightning__external_success__title).withAccent(accentColor = Colors.Purple), + description = stringResource(R.string.lightning__external_success__text) + .withAccent(accentStyle = SpanStyle(color = Colors.White, fontWeight = FontWeight.Bold)), + image = painterResource(R.drawable.switch_box), + buttonText = localizedRandom(R.string.common__ok_random), + onButtonClick = onContinue, + onCloseClick = onClose, + ) +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +private fun ExternalSuccessScreenPreview() { + AppThemeSurface { + ExternalSuccessScreen( + onContinue = {}, + onClose = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/shared/Channels.kt b/app/src/main/java/to/bitkit/ui/shared/Channels.kt index 88443bca7..55bfa069d 100644 --- a/app/src/main/java/to/bitkit/ui/shared/Channels.kt +++ b/app/src/main/java/to/bitkit/ui/shared/Channels.kt @@ -8,35 +8,30 @@ 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.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.Card +import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.ellipsisMiddle -import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors @Composable -internal fun Channels( +fun Channels( channels: List, hasPeers: Boolean, onChannelOpenTap: () -> Unit, @@ -57,25 +52,8 @@ internal fun Channels( HorizontalDivider() channels.forEach { Column(modifier = Modifier.padding(16.dp)) { - val outbound by remember(it) { mutableStateOf(it.outboundCapacityMsat / 1000u) } - val inboundHtlcMax by remember(it) { mutableStateOf(it.inboundHtlcMaximumMsat?.div(1000u) ?: 0u) } - val inboundHtlcMin by remember(it) { mutableStateOf(it.inboundHtlcMinimumMsat / 1000u) } - val nextOutboundHtlcLimit by remember(it) { mutableStateOf(it.nextOutboundHtlcLimitMsat / 1000u) } - val nextOutboundHtlcMin by remember(it) { mutableStateOf(it.nextOutboundHtlcMinimumMsat / 1000u) } - val inbound by remember(it) { mutableStateOf(it.inboundCapacityMsat / 1000u) } - - ChannelItem( - isReady = it.isChannelReady, - isUsable = it.isUsable, - isAnnounced = it.isAnnounced, - inboundHtlcMax = inboundHtlcMax.toLong(), - inboundHtlcMin = inboundHtlcMin.toLong(), - nextOutboundHtlcLimit = nextOutboundHtlcLimit.toLong(), - nextOutboundHtlcMin = nextOutboundHtlcMin.toLong(), - channelId = it.channelId, - outbound = outbound.toLong(), - inbound = inbound.toLong(), - confirmationsText = "Confirmations: ${it.confirmations ?: 0u}/${it.confirmationsRequired ?: 0u}", + ChannelItemUi( + channel = it, onClose = { onChannelCloseTap(it) }, ) } @@ -89,99 +67,75 @@ internal fun Channels( } @Composable -private fun ChannelItem( - isReady: Boolean, - isUsable: Boolean, - isAnnounced: Boolean, - inboundHtlcMax: Long, - inboundHtlcMin: Long, - nextOutboundHtlcLimit: Long, - nextOutboundHtlcMin: Long, - channelId: String, - outbound: Long, - inbound: Long, - confirmationsText: String, +private fun ChannelItemUi( + channel: ChannelDetails, onClose: () -> Unit, ) { + val outbound = (channel.outboundCapacityMsat / 1000u).toLong() + val inbound = (channel.inboundCapacityMsat / 1000u).toLong() + + val isReady = channel.isChannelReady + val isUsable = channel.isUsable + val isAnnounced = channel.isAnnounced + + val inboundHtlcMax = (channel.inboundHtlcMaximumMsat?.div(1000u) ?: 0u).toLong() + val inboundHtlcMin = (channel.inboundHtlcMinimumMsat / 1000u).toLong() + val nextOutboundHtlcLimit = (channel.nextOutboundHtlcLimitMsat / 1000u).toLong() + val nextOutboundHtlcMin = (channel.nextOutboundHtlcMinimumMsat / 1000u).toLong() + Column( verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = channelId.ellipsisMiddle(50), - style = MaterialTheme.typography.labelSmall, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - Card { - LinearProgressIndicator( - color = if (isReady) colorScheme.primary else colorScheme.error, - trackColor = colorScheme.surfaceVariant, - progress = (inbound.toDouble() / (outbound + inbound))::toFloat, - modifier = Modifier - .height(8.dp) - .fillMaxWidth(), - ) - } Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Text(moneyString(outbound), style = MaterialTheme.typography.labelSmall) - Text(moneyString(inbound), style = MaterialTheme.typography.labelSmall) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = if (isReady) "✅ Ready" else "⏳ Pending", style = MaterialTheme.typography.labelMedium) - Spacer(modifier = Modifier.weight(1f)) + Text( + text = channel.channelId.ellipsisMiddle(48), + style = MaterialTheme.typography.labelSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f) + ) BoxButton( onClick = onClose, modifier = Modifier - .clip(MaterialTheme.shapes.large) + .size(16.dp) + .clip(CircleShape) ) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Default.RemoveCircleOutline, contentDescription = stringResource(R.string.close), - tint = colorScheme.primary, + tint = Colors.Red, modifier = Modifier.size(16.dp) ) - Spacer(modifier = Modifier.size(4.dp)) - Text( - text = stringResource(R.string.close), - color = colorScheme.primary, - style = MaterialTheme.typography.labelMedium - ) } } + LinearProgressIndicator( + color = if (channel.isChannelReady) Colors.Purple else Colors.Gray5, + trackColor = Colors.Gray5, + progress = (inbound.toDouble() / (outbound + inbound))::toFloat, + modifier = Modifier + .height(8.dp) + .fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text(moneyString(outbound), style = MaterialTheme.typography.labelSmall) + Text(moneyString(inbound), style = MaterialTheme.typography.labelSmall) + } Column { val style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Normal) + + Text("Ready: ${if (channel.isChannelReady) "✅" else "❌"}", style = style) Text("Usable: ${if (isUsable) "✅" else "❌"}", style = style) Text("Announced: $isAnnounced", style = style) Text("Inbound htlc max: " + moneyString(inboundHtlcMax), style = style) Text("Inbound htlc min: " + moneyString(inboundHtlcMin), style = style) Text("Next outbound htlc limit: " + moneyString(nextOutboundHtlcLimit), style = style) Text("Next outbound htlc min: " + moneyString(nextOutboundHtlcMin), style = style) - Text(confirmationsText, style = style) + Text("Confirmations: ${channel.confirmations ?: 0u}/${channel.confirmationsRequired ?: 0u}", style = style) } } } - -@Suppress("SpellCheckingInspection") -@Preview(showBackground = true) -@Composable -private fun ChannelItemPreview() { - AppThemeSurface { - ChannelItem( - isReady = true, - isUsable = true, - channelId = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", - outbound = 1000, - inbound = 1000, - onClose = {}, - isAnnounced = false, - inboundHtlcMax = 123L, - inboundHtlcMin = 246L, - nextOutboundHtlcLimit = 531L, - nextOutboundHtlcMin = 762L, - confirmationsText = "Confirmations: 1/2", - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/shared/Peers.kt b/app/src/main/java/to/bitkit/ui/shared/Peers.kt index 190a37827..0492e0085 100644 --- a/app/src/main/java/to/bitkit/ui/shared/Peers.kt +++ b/app/src/main/java/to/bitkit/ui/shared/Peers.kt @@ -10,11 +10,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ext.ellipsisMiddle import to.bitkit.models.LnPeer +import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Green500 @Composable @@ -73,9 +73,9 @@ internal fun Peers( .clip(CircleShape) ) { Icon( - imageVector = Icons.Outlined.StopCircle, - contentDescription = stringResource(R.string.close), - tint = colorScheme.primary, + imageVector = Icons.Default.RemoveCircleOutline, + contentDescription = stringResource(R.string.common__close), + tint = Colors.Red, modifier = Modifier.size(16.dp) ) } diff --git a/app/src/main/java/to/bitkit/ui/theme/Shape.kt b/app/src/main/java/to/bitkit/ui/theme/Shape.kt index aba3c5564..239fd3e32 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Shape.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Shape.kt @@ -14,4 +14,5 @@ val Shapes = Shapes( object AppShapes { val sheet = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) val smallButton = RoundedCornerShape(8.dp) + val smallInput = RoundedCornerShape(8.dp) } diff --git a/app/src/main/java/to/bitkit/utils/ResourceProvider.kt b/app/src/main/java/to/bitkit/utils/ResourceProvider.kt index 0e8bcb6de..b1ba730f2 100644 --- a/app/src/main/java/to/bitkit/utils/ResourceProvider.kt +++ b/app/src/main/java/to/bitkit/utils/ResourceProvider.kt @@ -1,17 +1,15 @@ package to.bitkit.utils -import android.annotation.SuppressLint import android.content.Context import androidx.annotation.StringRes +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton -// TODO find a better way ?! -@SuppressLint("StaticFieldLeak") -object ResourceProvider { - private lateinit var context: Context - - fun init(appContext: Context) { - context = appContext.applicationContext - } +@Singleton +class ResourceProvider @Inject constructor( + @ApplicationContext private val context: Context, +) { fun getString(@StringRes resId: Int): String { return context.getString(resId) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 77e0f0b71..7b5b95410 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,9 +14,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event @@ -25,7 +22,9 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.Txid import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.WatchResult import to.bitkit.ext.removeSpaces +import to.bitkit.ext.watchUntil import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -42,7 +41,6 @@ import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import uniffi.bitkitcore.Activity import uniffi.bitkitcore.ActivityFilter -import uniffi.bitkitcore.ActivityType import uniffi.bitkitcore.LightningInvoice import uniffi.bitkitcore.OnChainInvoice import uniffi.bitkitcore.PaymentType @@ -573,37 +571,31 @@ class AppViewModel @Inject constructor( ): Result { return try { val hash = lightningService.send(bolt11 = bolt11, amount) - val result = CompletableDeferred>() - ldkNodeEventBus.events - .takeWhile { event -> - when (event) { - is Event.PaymentSuccessful -> { - if (event.paymentHash == hash) { - result.complete(Result.success(hash)) - false // Stop collecting - } else { - true // Keep listening for other events - } + // Wait until matching payment event is received + val result = ldkNodeEventBus.events.watchUntil { event -> + when (event) { + is Event.PaymentSuccessful -> { + if (event.paymentHash == hash) { + WatchResult.Complete(Result.success(hash)) + } else { + WatchResult.Continue() } + } - is Event.PaymentFailed -> { - if (event.paymentHash == hash) { - result.complete( - Result.failure(Exception(event.reason?.name ?: "Unknown payment failure reason")) - ) - false // Stop collecting - } else { - true // Keep listening for other events - } + is Event.PaymentFailed -> { + if (event.paymentHash == hash) { + val error = Exception(event.reason?.name ?: "Unknown payment failure reason") + WatchResult.Complete(Result.failure(error)) + } else { + WatchResult.Continue() } - - else -> true // Continue collecting } - } - .collect() - result.await() + else -> WatchResult.Continue() + } + } + result } catch (e: Exception) { toast( type = Toast.ToastType.ERROR, @@ -712,7 +704,7 @@ data class SendUiState( val description: String = "", val isUnified: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, - val selectedTags: List = listOf(), //TODO save tags in other PR + val selectedTags: List = listOf(), val decodedInvoice: LightningInvoice? = null, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt new file mode 100644 index 000000000..592d20279 --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/ExternalNodeViewModel.kt @@ -0,0 +1,154 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.UserChannelId +import to.bitkit.R +import to.bitkit.ext.WatchResult +import to.bitkit.ext.watchUntil +import to.bitkit.models.LnPeer +import to.bitkit.models.Toast +import to.bitkit.services.LdkNodeEventBus +import to.bitkit.services.LightningService +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.ResourceProvider +import to.bitkit.viewmodels.ExternalNodeContract.SideEffect +import to.bitkit.viewmodels.ExternalNodeContract.UiState +import javax.inject.Inject + +@HiltViewModel +class ExternalNodeViewModel @Inject constructor( + private val resourceProvider: ResourceProvider, + private val lightningService: LightningService, + private val ldkNodeEventBus: LdkNodeEventBus, +) : ViewModel() { + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + private fun setEffect(effect: SideEffect) = viewModelScope.launch { _effects.emit(effect) } + + fun onConnectionContinue(peer: LnPeer) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val result = lightningService.connectPeer(peer) + + _uiState.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + _uiState.update { it.copy(peer = peer) } + setEffect(SideEffect.ConnectionSuccess) + } else { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = resourceProvider.getString(R.string.lightning__error_add_title), + description = resourceProvider.getString(R.string.lightning__error_add), + ) + } + } + } + + fun onConnectionPaste(clipboardText: String) { + viewModelScope.launch { + val result = LnPeer.parseUri(clipboardText) + + if (result.isSuccess) { + _uiState.update { it.copy(peer = result.getOrNull()) } + } else { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = resourceProvider.getString(R.string.lightning__error_add_uri), + ) + } + } + } + + fun onAmountContinue(satsAmount: Long) { + _uiState.update { it.copy(localBalance = satsAmount) } + } + + fun onConfirm() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + val result = lightningService.openChannel( + peer = requireNotNull(_uiState.value.peer), + channelAmountSats = _uiState.value.localBalance.toULong(), + ) + + if (result.isSuccess) { + val userChannelId = requireNotNull(result.getOrNull()) + + // Wait until matching channel event is received + val initResult = awaitChannelInitResult(userChannelId) + + if (initResult.isSuccess) { + setEffect(SideEffect.ConfirmSuccess) + } else { + failConfirm(initResult.exceptionOrNull()?.message.orEmpty()) + } + } else { + failConfirm(result.exceptionOrNull()?.message.orEmpty()) + } + } + } + + private suspend fun failConfirm(errorMessage: String) { + _uiState.update { it.copy(isLoading = false) } + + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = resourceProvider.getString(R.string.lightning__error_channel_purchase), + description = resourceProvider + .getString(R.string.lightning__error_channel_setup_msg) + .replace("{raw}", errorMessage), + ) + } + + private suspend fun awaitChannelInitResult(userChannelId: UserChannelId): Result { + return ldkNodeEventBus.events.watchUntil { event -> + when (event) { + is Event.ChannelClosed -> { + if (event.userChannelId == userChannelId) { + WatchResult.Complete(Result.failure(Exception("${event.reason}"))) + } else { + WatchResult.Continue() + } + } + + is Event.ChannelPending -> { + if (event.userChannelId == userChannelId) { + WatchResult.Complete(Result.success(Unit)) + } else { + WatchResult.Continue() + } + } + + else -> WatchResult.Continue() + } + } + } +} + +interface ExternalNodeContract { + data class UiState( + val isLoading: Boolean = false, + val peer: LnPeer? = null, + val localBalance: Long = 0, + ) + + sealed class SideEffect { + data object ConnectionSuccess : SideEffect() + data object ConfirmSuccess : SideEffect() + } +} diff --git a/app/src/main/res/drawable/ic_clipboard_text_simple.xml b/app/src/main/res/drawable/ic_clipboard_text_simple.xml new file mode 100644 index 000000000..86231310a --- /dev/null +++ b/app/src/main/res/drawable/ic_clipboard_text_simple.xml @@ -0,0 +1,27 @@ + + + + + + +