diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/peer')
15 files changed, 2048 insertions, 0 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt new file mode 100644 index 0000000..4393e47 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt @@ -0,0 +1,128 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 net.taler.wallet.R +import net.taler.wallet.compose.NumericInputField +import net.taler.wallet.compose.SelectionChip +import net.taler.wallet.compose.TalerSurface + +enum class ExpirationOption(val hours: Long) { + DAYS_1(24), + DAYS_7(24 * 7), + DAYS_30(24 * 30), + CUSTOM(-1) +} + +@Composable +fun ExpirationComposable( + modifier: Modifier = Modifier, + option: ExpirationOption, + hours: Long, + onOptionChange: (ExpirationOption) -> Unit, + onHoursChange: (Long) -> Unit, +) { + val options = listOf( + ExpirationOption.DAYS_1 to stringResource(R.string.send_peer_expiration_1d), + ExpirationOption.DAYS_7 to stringResource(R.string.send_peer_expiration_7d), + ExpirationOption.DAYS_30 to stringResource(R.string.send_peer_expiration_30d), + ExpirationOption.CUSTOM to stringResource(R.string.send_peer_expiration_custom), + ) + Column( + modifier = modifier, + ) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + items(items = options, key = { it.first }) { + SelectionChip( + label = { Text(it.second) }, + modifier = Modifier.padding(horizontal = 4.dp), + selected = it.first == option, + value = it.first, + onSelected = { o -> + onOptionChange(o) + if (o != ExpirationOption.CUSTOM) { + onHoursChange(o.hours) + } + }, + ) + } + } + + if (option == ExpirationOption.CUSTOM) { + val d = hours / 24L + val h = hours - d * 24L + Row( + modifier = Modifier.fillMaxWidth(), + ) { + NumericInputField( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(end = 4.dp), + value = d, + onValueChange = { + onHoursChange(it * 24 + h) + }, + label = { Text(stringResource(R.string.send_peer_expiration_days)) }, + maxValue = 365, + ) + NumericInputField( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 4.dp), + value = h, + onValueChange = { + onHoursChange(d * 24 + it) + }, + label = { Text(stringResource(R.string.send_peer_expiration_hours)) }, + maxValue = 23, + ) + } + } + } +} + +@Preview +@Composable +fun ExpirationComposablePreview() { + TalerSurface { + var option = ExpirationOption.CUSTOM + var hours = 25L + ExpirationComposable( + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt new file mode 100644 index 0000000..1ce0175 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt @@ -0,0 +1,270 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.annotation.SuppressLint +import androidx.annotation.StringRes +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE +import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE +import net.taler.wallet.backend.TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED +import net.taler.wallet.backend.TalerErrorInfo + +data class IncomingData( + val isCredit: Boolean, + @StringRes val intro: Int, + @StringRes val button: Int, +) + +val incomingPush = IncomingData( + isCredit = true, + intro = R.string.receive_peer_payment_intro, + button = R.string.receive_peer_payment_title, +) + +val incomingPull = IncomingData( + isCredit = false, + intro = R.string.pay_peer_intro, + button = R.string.payment_button_confirm, +) + +@Composable +fun IncomingComposable( + state: State<IncomingState>, + data: IncomingData, + onAccept: (IncomingTerms) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + Text( + modifier = Modifier + .padding(16.dp) + .align(CenterHorizontally), + text = stringResource(id = data.intro), + ) + when (val s = state.value) { + IncomingChecking -> PeerPullCheckingComposable() + is IncomingAccepting -> PeerPullTermsComposable(s, onAccept, data) + is IncomingTerms -> PeerPullTermsComposable(s, onAccept, data) + is IncomingError -> PeerPullErrorComposable(s) + IncomingAccepted -> { + // we navigate away, don't show anything + } + } + } +} + +@Composable +fun ColumnScope.PeerPullCheckingComposable() { + CircularProgressIndicator( + modifier = Modifier + .align(CenterHorizontally) + .fillMaxSize(0.75f), + ) +} + +@Composable +fun ColumnScope.PeerPullTermsComposable( + terms: IncomingTerms, + onAccept: (IncomingTerms) -> Unit, + data: IncomingData, +) { + Text( + modifier = Modifier + .padding(16.dp) + .align(CenterHorizontally), + text = terms.contractTerms.summary, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.weight(1f)) + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.align(End), + ) { + Text( + text = stringResource(id = R.string.payment_label_amount_total), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = terms.contractTerms.amount.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + // this gets used for credit and debit, so fee calculation differs + val fee = if (data.isCredit) { + terms.amountRaw - terms.amountEffective + } else { + terms.amountEffective - terms.amountRaw + } + val feeStr = if (data.isCredit) { + stringResource(R.string.amount_negative, fee) + } else { + stringResource(R.string.amount_positive, fee) + } + if (!fee.isZero()) Text( + modifier = Modifier.align(End), + text = feeStr, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + if (terms is IncomingAccepting) { + CircularProgressIndicator( + modifier = Modifier + .padding(end = 64.dp) + .align(End), + ) + } else { + Button( + modifier = Modifier + .align(End) + .padding(top = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.green), + contentColor = Color.White, + ), + onClick = { onAccept(terms) }, + ) { + Text( + text = stringResource(id = data.button), + ) + } + } + } + } +} + +@Composable +fun ColumnScope.PeerPullErrorComposable(s: IncomingError) { + val message = when (s.info.code) { + WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE -> stringResource(R.string.payment_balance_insufficient) + WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE -> stringResource(R.string.payment_balance_insufficient) + else -> s.info.userFacingMsg + } + + Text( + modifier = Modifier + .align(CenterHorizontally) + .padding(horizontal = 32.dp), + text = message, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error, + ) +} + +@Preview +@Composable +fun PeerPullCheckingPreview() { + Surface { + @SuppressLint("UnrememberedMutableState") + val s = mutableStateOf(IncomingChecking) + IncomingComposable(s, incomingPush) {} + } +} + +@Preview +@Composable +fun PeerPullTermsPreview() { + Surface { + val terms = IncomingTerms( + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.423"), + contractTerms = PeerContractTerms( + summary = "This is a long test summary that can be more than one line long for sure", + amount = Amount.fromString("TESTKUDOS", "23.42"), + ), + id = "ID123", + ) + + @SuppressLint("UnrememberedMutableState") + val s = mutableStateOf(terms) + IncomingComposable(s, incomingPush) {} + } +} + +@Preview +@Composable +fun PeerPullAcceptingPreview() { + Surface { + val terms = IncomingTerms( + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.123"), + contractTerms = PeerContractTerms( + summary = "This is a long test summary that can be more than one line long for sure", + amount = Amount.fromString("TESTKUDOS", "23.42"), + ), + id = "ID123", + ) + + @SuppressLint("UnrememberedMutableState") + val s = mutableStateOf(IncomingAccepting(terms)) + IncomingComposable(s, incomingPush) {} + } +} + +@Preview +@Composable +fun PeerPullPayErrorPreview() { + Surface { + @SuppressLint("UnrememberedMutableState") + val s = mutableStateOf( + IncomingError( + info = TalerErrorInfo(WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "msg"), + ) + ) + IncomingComposable(s, incomingPush) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt new file mode 100644 index 0000000..df71c72 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingPullPaymentFragment.kt @@ -0,0 +1,73 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import net.taler.common.showError +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class IncomingPullPaymentFragment : Fragment() { + private val model: MainViewModel by activityViewModels() + private val peerManager get() = model.peerManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + lifecycleScope.launchWhenResumed { + peerManager.incomingPullState.collect { + if (it is IncomingAccepted) { + findNavController().navigate(R.id.action_promptPullPayment_to_nav_main) + } else if (it is IncomingError) { + if (model.devMode.value == true) { + showError(it.info) + } else { + showError(it.info.userFacingMsg) + } + } + } + } + return ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val state = peerManager.incomingPullState.collectAsStateLifecycleAware() + IncomingComposable(state, incomingPull) { terms -> + peerManager.confirmPeerPullDebit(terms) + } + } + } + } + } + + override fun onStart() { + super.onStart() + activity?.setTitle(R.string.pay_peer_title) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt new file mode 100644 index 0000000..ced2b82 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingPushPaymentFragment.kt @@ -0,0 +1,73 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import net.taler.common.showError +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class IncomingPushPaymentFragment : Fragment() { + private val model: MainViewModel by activityViewModels() + private val peerManager get() = model.peerManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + lifecycleScope.launchWhenResumed { + peerManager.incomingPushState.collect { + if (it is IncomingAccepted) { + findNavController().navigate(R.id.action_promptPushPayment_to_nav_main) + } else if (it is IncomingError) { + if (model.devMode.value == true) { + showError(it.info) + } else { + showError(it.info.userFacingMsg) + } + } + } + } + return ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val state = peerManager.incomingPushState.collectAsStateLifecycleAware() + IncomingComposable(state, incomingPush) { terms -> + peerManager.confirmPeerPushCredit(terms) + } + } + } + } + } + + override fun onStart() { + super.onStart() + activity?.setTitle(R.string.receive_peer_payment_title) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt new file mode 100644 index 0000000..cd5b5dd --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt @@ -0,0 +1,60 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import kotlinx.serialization.Serializable +import net.taler.common.Amount +import net.taler.wallet.backend.TalerErrorInfo + +sealed class IncomingState +object IncomingChecking : IncomingState() +open class IncomingTerms( + val amountRaw: Amount, + val amountEffective: Amount, + val contractTerms: PeerContractTerms, + val id: String, +) : IncomingState() + +class IncomingAccepting(s: IncomingTerms) : + IncomingTerms(s.amountRaw, s.amountEffective, s.contractTerms, s.id) + +object IncomingAccepted : IncomingState() +data class IncomingError( + val info: TalerErrorInfo, +) : IncomingState() + +@Serializable +data class PeerContractTerms( + val summary: String, + val amount: Amount, +) + +@Serializable +data class PreparePeerPullDebitResponse( + val contractTerms: PeerContractTerms, + val amountRaw: Amount, + val amountEffective: Amount, + val transactionId: String, +) + +@Serializable +data class PreparePeerPushCreditResponse( + val contractTerms: PeerContractTerms, + val amountRaw: Amount, + val amountEffective: Amount, + val transactionId: String, +) diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt new file mode 100644 index 0000000..90b520e --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullComposable.kt @@ -0,0 +1,280 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.serialization.json.JsonPrimitive +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import kotlin.random.Random + +@Composable +fun OutgoingPullComposable( + amount: Amount, + state: OutgoingState, + onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit, + onClose: () -> Unit, +) { + when(state) { + is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() + is OutgoingIntro, is OutgoingChecked -> OutgoingPullIntroComposable( + amount = amount, + state = state, + onCreateInvoice = onCreateInvoice, + ) + is OutgoingError -> PeerErrorComposable(state, onClose) + } +} + +@Composable +fun PeerCreatingComposable() { + Box( + modifier = Modifier + .fillMaxSize(), + ) { + CircularProgressIndicator( + modifier = Modifier + .padding(32.dp) + .align(Center), + ) + } +} + +@Composable +fun OutgoingPullIntroComposable( + amount: Amount, + state: OutgoingState, + onCreateInvoice: (amount: Amount, subject: String, hours: Long, exchange: ExchangeItem) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + var subject by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + singleLine = true, + value = subject, + onValueChange = { input -> + if (input.length <= MAX_LENGTH_SUBJECT) + subject = input.replace('\n', ' ') + }, + isError = subject.isBlank(), + label = { + Text( + stringResource(R.string.send_peer_purpose), + color = if (subject.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified, + text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT), + textAlign = TextAlign.End, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_chosen), + amount = amount, + amountType = AmountType.Positive, + ) + + if (state is OutgoingChecked) { + val fee = state.amountRaw - state.amountEffective + if (!fee.isZero()) TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(amount.spec), + amountType = AmountType.Negative, + ) + } + + val exchangeItem = (state as? OutgoingChecked)?.exchangeItem + TransactionInfoComposable( + label = stringResource(id = R.string.withdraw_exchange), + info = if (exchangeItem == null) "" else cleanExchange(exchangeItem.exchangeBaseUrl), + ) + + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.send_peer_expiration_period), + style = MaterialTheme.typography.bodyMedium, + ) + + var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) } + var hours by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY.hours) } + ExpirationComposable( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + + Button( + modifier = Modifier.padding(16.dp), + enabled = subject.isNotBlank() && state is OutgoingChecked, + onClick = { + onCreateInvoice( + amount, + subject, + hours, + exchangeItem ?: error("clickable without exchange") + ) + }, + ) { + Text(text = stringResource(R.string.receive_peer_create_button)) + } + } +} + +@Composable +fun PeerErrorComposable(state: OutgoingError, onClose: () -> Unit) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = CenterHorizontally, + ) { + Text( + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + text = state.info.userFacingMsg, + ) + + Button( + modifier = Modifier.padding(16.dp), + onClick = onClose, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(text = stringResource(R.string.close)) + } + } +} + +@Preview +@Composable +fun PeerPullComposableCreatingPreview() { + TalerSurface { + OutgoingPullComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = OutgoingCreating, + onCreateInvoice = { _, _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPullComposableCheckingPreview() { + TalerSurface { + OutgoingPullComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking, + onCreateInvoice = { _, _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPullComposableCheckedPreview() { + TalerSurface { + val amountRaw = Amount.fromString("TESTKUDOS", "42.42") + val amountEffective = Amount.fromString("TESTKUDOS", "42.23") + val exchangeItem = ExchangeItem("https://example.org", "TESTKUDOS", emptyList()) + OutgoingPullComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = OutgoingChecked(amountRaw, amountEffective, exchangeItem), + onCreateInvoice = { _, _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPullComposableErrorPreview() { + TalerSurface { + val json = mapOf("foo" to JsonPrimitive("bar")) + val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) + OutgoingPullComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = state, + onCreateInvoice = { _, _, _, _ -> }, + onClose = {}, + ) + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt new file mode 100644 index 0000000..8f2fb96 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPullFragment.kt @@ -0,0 +1,107 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.showError + +class OutgoingPullFragment : Fragment() { + private val model: MainViewModel by activityViewModels() + private val peerManager get() = model.peerManager + private val transactionManager get() = model.transactionManager + private val balanceManager get() = model.balanceManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val amount = arguments?.getString("amount")?.let { + Amount.fromJSONString(it) + } ?: error("no amount passed") + val scopeInfo = transactionManager.selectedScope + val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } + + return ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val state = peerManager.pullState.collectAsStateLifecycleAware().value + OutgoingPullComposable( + amount = amount.withSpec(spec), + state = state, + onCreateInvoice = this@OutgoingPullFragment::onCreateInvoice, + onClose = { + findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) + } + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + peerManager.pullState.collect { + if (it is OutgoingResponse) { + if (transactionManager.selectTransaction(it.transactionId)) { + findNavController().navigate(R.id.action_nav_peer_pull_to_nav_transactions_detail_peer) + } else { + findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) + } + } + + if (it is OutgoingError && model.devMode.value == true) { + showError(it.info) + } + } + } + } + } + + override fun onStart() { + super.onStart() + activity?.setTitle(R.string.receive_peer_title) + } + + override fun onDestroy() { + super.onDestroy() + if (!requireActivity().isChangingConfigurations) peerManager.resetPullPayment() + } + + private fun onCreateInvoice(amount: Amount, summary: String, hours: Long, exchange: ExchangeItem) { + peerManager.initiatePeerPullCredit(amount, summary, hours, exchange) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt new file mode 100644 index 0000000..d39fdc8 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushComposable.kt @@ -0,0 +1,218 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.serialization.json.JsonPrimitive +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.TalerSurface +import kotlin.random.Random + +@Composable +fun OutgoingPushComposable( + state: OutgoingState, + amount: Amount, + onSend: (amount: Amount, summary: String, hours: Long) -> Unit, + onClose: () -> Unit, +) { + when(state) { + is OutgoingChecking, is OutgoingCreating, is OutgoingResponse -> PeerCreatingComposable() + is OutgoingIntro, is OutgoingChecked -> OutgoingPushIntroComposable( + amount = amount, + state = state, + onSend = onSend, + ) + is OutgoingError -> PeerErrorComposable(state, onClose) + } +} + +@Composable +fun OutgoingPushIntroComposable( + state: OutgoingState, + amount: Amount, + onSend: (amount: Amount, summary: String, hours: Long) -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(scrollState), + horizontalAlignment = CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = amount.toString(), + softWrap = false, + style = MaterialTheme.typography.titleLarge, + ) + + if (state is OutgoingChecked) { + val fee = state.amountEffective - state.amountRaw + Text( + modifier = Modifier.padding(vertical = 16.dp), + text = stringResource(id = R.string.payment_fee, fee.withSpec(amount.spec)), + softWrap = false, + color = MaterialTheme.colorScheme.error, + ) + } + + var subject by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + singleLine = true, + value = subject, + onValueChange = { input -> + if (input.length <= MAX_LENGTH_SUBJECT) + subject = input.replace('\n', ' ') + }, + isError = subject.isBlank(), + label = { + Text( + stringResource(R.string.send_peer_purpose), + color = if (subject.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + color = if (subject.isBlank()) MaterialTheme.colorScheme.error else Color.Unspecified, + text = stringResource(R.string.char_count, subject.length, MAX_LENGTH_SUBJECT), + textAlign = TextAlign.End, + ) + + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.send_peer_expiration_period), + style = MaterialTheme.typography.bodyMedium, + ) + + var option by rememberSaveable { mutableStateOf(DEFAULT_EXPIRY) } + var hours by rememberSaveable { mutableLongStateOf(DEFAULT_EXPIRY.hours) } + ExpirationComposable( + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + option = option, + hours = hours, + onOptionChange = { option = it } + ) { hours = it } + + Button( + enabled = state is OutgoingChecked && subject.isNotBlank(), + onClick = { onSend(amount, subject, hours) }, + ) { + Text(text = stringResource(R.string.send_peer_create_button)) + } + } +} + +@Preview +@Composable +fun PeerPushComposableCreatingPreview() { + TalerSurface { + OutgoingPushComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = OutgoingCreating, + onSend = { _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPushComposableCheckingPreview() { + TalerSurface { + val state = if (Random.nextBoolean()) OutgoingIntro else OutgoingChecking + OutgoingPushComposable( + state = state, + amount = Amount.fromString("TESTKUDOS", "42.23"), + onSend = { _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPushComposableCheckedPreview() { + TalerSurface { + val amountEffective = Amount.fromString("TESTKUDOS", "42.42") + val amountRaw = Amount.fromString("TESTKUDOS", "42.23") + val state = OutgoingChecked(amountRaw, amountEffective) + OutgoingPushComposable( + state = state, + amount = amountEffective, + onSend = { _, _, _ -> }, + onClose = {}, + ) + } +} + +@Preview +@Composable +fun PeerPushComposableErrorPreview() { + TalerSurface { + val json = mapOf("foo" to JsonPrimitive("bar")) + val state = OutgoingError(TalerErrorInfo(TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, "hint", "message", json)) + OutgoingPushComposable( + amount = Amount.fromString("TESTKUDOS", "42.23"), + state = state, + onSend = { _, _, _ -> }, + onClose = {}, + ) + } +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt new file mode 100644 index 0000000..01fb566 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingPushFragment.kt @@ -0,0 +1,122 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import net.taler.common.Amount +import net.taler.wallet.MainViewModel +import net.taler.wallet.R +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.showError + +class OutgoingPushFragment : Fragment() { + private val model: MainViewModel by activityViewModels() + private val peerManager get() = model.peerManager + private val transactionManager get() = model.transactionManager + private val balanceManager get() = model.balanceManager + + // hacky way to change back action until we have navigation for compose + private val backPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + findNavController().navigate(R.id.action_nav_peer_push_to_nav_main) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val amount = arguments?.getString("amount")?.let { + Amount.fromJSONString(it) + } ?: error("no amount passed") + val scopeInfo = transactionManager.selectedScope + val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, backPressedCallback + ) + + return ComposeView(requireContext()).apply { + setContent { + TalerSurface { + val state = peerManager.pushState.collectAsStateLifecycleAware().value + OutgoingPushComposable( + amount = amount.withSpec(spec), + state = state, + onSend = this@OutgoingPushFragment::onSend, + onClose = { + findNavController().navigate(R.id.action_nav_peer_pull_to_nav_main) + } + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + peerManager.pushState.collect { + if (it is OutgoingResponse) { + if (transactionManager.selectTransaction(it.transactionId)) { + findNavController().navigate(R.id.action_nav_peer_push_to_nav_transactions_detail_peer) + } else { + findNavController().navigate(R.id.action_nav_peer_push_to_nav_main) + } + } + + if (it is OutgoingError && model.devMode.value == true) { + showError(it.info) + } + + // Disable back navigation when tx is being created + backPressedCallback.isEnabled = it !is OutgoingCreating + } + } + } + } + + override fun onStart() { + super.onStart() + activity?.setTitle(R.string.send_peer_title) + } + + override fun onDestroy() { + super.onDestroy() + if (!requireActivity().isChangingConfigurations) peerManager.resetPushPayment() + } + + private fun onSend(amount: Amount, summary: String, hours: Long) { + peerManager.initiatePeerPushDebit(amount, summary, hours) + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt new file mode 100644 index 0000000..05da294 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/OutgoingState.kt @@ -0,0 +1,63 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import kotlinx.serialization.Serializable +import net.taler.common.Amount +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.exchanges.ExchangeItem + +sealed class OutgoingState +object OutgoingIntro : OutgoingState() +object OutgoingChecking : OutgoingState() +data class OutgoingChecked( + val amountRaw: Amount, + val amountEffective: Amount, + val exchangeItem: ExchangeItem? = null, +) : OutgoingState() +object OutgoingCreating : OutgoingState() +data class OutgoingResponse( + val transactionId: String, +) : OutgoingState() + +data class OutgoingError( + val info: TalerErrorInfo, +) : OutgoingState() + +@Serializable +data class CheckPeerPullCreditResponse( + val exchangeBaseUrl: String, + val amountRaw: Amount, + val amountEffective: Amount, +) + +@Serializable +data class InitiatePeerPullPaymentResponse( + val transactionId: String, +) + +@Serializable +data class CheckPeerPushDebitResponse( + val amountRaw: Amount, + val amountEffective: Amount, +) + +@Serializable +data class InitiatePeerPushDebitResponse( + val exchangeBaseUrl: String, + val transactionId: String, +) diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt new file mode 100644 index 0000000..5bd2b0b --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -0,0 +1,217 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.wallet.TAG +import net.taler.wallet.backend.TalerErrorCode.UNKNOWN +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.exchanges.ExchangeItem +import net.taler.wallet.exchanges.ExchangeManager +import org.json.JSONObject +import java.util.concurrent.TimeUnit.HOURS + +const val MAX_LENGTH_SUBJECT = 100 +val DEFAULT_EXPIRY = ExpirationOption.DAYS_1 + +class PeerManager( + private val api: WalletBackendApi, + private val exchangeManager: ExchangeManager, + private val scope: CoroutineScope, +) { + + private val _outgoingPullState = MutableStateFlow<OutgoingState>(OutgoingIntro) + val pullState: StateFlow<OutgoingState> = _outgoingPullState + + private val _outgoingPushState = MutableStateFlow<OutgoingState>(OutgoingIntro) + val pushState: StateFlow<OutgoingState> = _outgoingPushState + + private val _incomingPullState = MutableStateFlow<IncomingState>(IncomingChecking) + val incomingPullState: StateFlow<IncomingState> = _incomingPullState + + private val _incomingPushState = MutableStateFlow<IncomingState>(IncomingChecking) + val incomingPushState: StateFlow<IncomingState> = _incomingPushState + + fun checkPeerPullCredit(amount: Amount) { + _outgoingPullState.value = OutgoingChecking + scope.launch(Dispatchers.IO) { + val exchangeItem = exchangeManager.findExchange(amount.currency) + if (exchangeItem == null) { + _outgoingPullState.value = OutgoingError( + TalerErrorInfo(UNKNOWN, "No exchange found for ${amount.currency}") + ) + return@launch + } + api.request("checkPeerPullCredit", CheckPeerPullCreditResponse.serializer()) { + put("exchangeBaseUrl", exchangeItem.exchangeBaseUrl) + put("amount", amount.toJSONString()) + }.onSuccess { + _outgoingPullState.value = OutgoingChecked( + amountRaw = it.amountRaw, + amountEffective = it.amountEffective, + exchangeItem = exchangeItem, + ) + }.onError { error -> + Log.e(TAG, "got checkPeerPullCredit error result $error") + _outgoingPullState.value = OutgoingError(error) + } + } + } + + fun initiatePeerPullCredit(amount: Amount, summary: String, expirationHours: Long, exchange: ExchangeItem) { + _outgoingPullState.value = OutgoingCreating + scope.launch(Dispatchers.IO) { + val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) + api.request("initiatePeerPullCredit", InitiatePeerPullPaymentResponse.serializer()) { + put("exchangeBaseUrl", exchange.exchangeBaseUrl) + put("partialContractTerms", JSONObject().apply { + put("amount", amount.toJSONString()) + put("summary", summary) + put("purse_expiration", JSONObject(Json.encodeToString(expiry))) + }) + }.onSuccess { + _outgoingPullState.value = OutgoingResponse(it.transactionId) + }.onError { error -> + Log.e(TAG, "got initiatePeerPullCredit error result $error") + _outgoingPullState.value = OutgoingError(error) + } + } + } + + fun resetPullPayment() { + _outgoingPullState.value = OutgoingIntro + } + + fun checkPeerPushDebit(amount: Amount) { + _outgoingPushState.value = OutgoingChecking + scope.launch(Dispatchers.IO) { + api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) { + put("amount", amount.toJSONString()) + }.onSuccess { response -> + _outgoingPushState.value = OutgoingChecked( + amountRaw = response.amountRaw, + amountEffective = response.amountEffective, + // FIXME add exchangeItem once available in API + ) + }.onError { error -> + Log.e(TAG, "got checkPeerPushDebit error result $error") + _outgoingPushState.value = OutgoingError(error) + } + } + } + + fun initiatePeerPushDebit(amount: Amount, summary: String, expirationHours: Long) { + _outgoingPushState.value = OutgoingCreating + scope.launch(Dispatchers.IO) { + val expiry = Timestamp.fromMillis(System.currentTimeMillis() + HOURS.toMillis(expirationHours)) + api.request("initiatePeerPushDebit", InitiatePeerPushDebitResponse.serializer()) { + put("amount", amount.toJSONString()) + put("partialContractTerms", JSONObject().apply { + put("amount", amount.toJSONString()) + put("summary", summary) + put("purse_expiration", JSONObject(Json.encodeToString(expiry))) + }) + }.onSuccess { response -> + _outgoingPushState.value = OutgoingResponse(response.transactionId) + }.onError { error -> + Log.e(TAG, "got initiatePeerPushDebit error result $error") + _outgoingPushState.value = OutgoingError(error) + } + } + } + + fun resetPushPayment() { + _outgoingPushState.value = OutgoingIntro + } + + fun preparePeerPullDebit(talerUri: String) { + _incomingPullState.value = IncomingChecking + scope.launch(Dispatchers.IO) { + api.request("preparePeerPullDebit", PreparePeerPullDebitResponse.serializer()) { + put("talerUri", talerUri) + }.onSuccess { response -> + _incomingPullState.value = IncomingTerms( + amountRaw = response.amountRaw, + amountEffective = response.amountEffective, + contractTerms = response.contractTerms, + id = response.transactionId, + ) + }.onError { error -> + Log.e(TAG, "got preparePeerPullDebit error result $error") + _incomingPullState.value = IncomingError(error) + } + } + } + + fun confirmPeerPullDebit(terms: IncomingTerms) { + _incomingPullState.value = IncomingAccepting(terms) + scope.launch(Dispatchers.IO) { + api.request<Unit>("confirmPeerPullDebit") { + put("transactionId", terms.id) + }.onSuccess { + _incomingPullState.value = IncomingAccepted + }.onError { error -> + Log.e(TAG, "got confirmPeerPullDebit error result $error") + _incomingPullState.value = IncomingError(error) + } + } + } + + fun preparePeerPushCredit(talerUri: String) { + _incomingPushState.value = IncomingChecking + scope.launch(Dispatchers.IO) { + api.request("preparePeerPushCredit", PreparePeerPushCreditResponse.serializer()) { + put("talerUri", talerUri) + }.onSuccess { response -> + _incomingPushState.value = IncomingTerms( + amountRaw = response.amountRaw, + amountEffective = response.amountEffective, + contractTerms = response.contractTerms, + id = response.transactionId, + ) + }.onError { error -> + Log.e(TAG, "got preparePeerPushCredit error result $error") + _incomingPushState.value = IncomingError(error) + } + } + } + + fun confirmPeerPushCredit(terms: IncomingTerms) { + _incomingPushState.value = IncomingAccepting(terms) + scope.launch(Dispatchers.IO) { + api.request<Unit>("confirmPeerPushCredit") { + put("transactionId", terms.id) + }.onSuccess { + _incomingPushState.value = IncomingAccepted + }.onError { error -> + Log.e(TAG, "got confirmPeerPushCredit error result $error") + _incomingPushState.value = IncomingError(error) + } + } + } + +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt new file mode 100644 index 0000000..3b15b6f --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt @@ -0,0 +1,105 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.PeerInfoShort +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState.CreatePurse +import net.taler.wallet.transactions.TransactionMinorState.Ready +import net.taler.wallet.transactions.TransactionPeerComposable +import net.taler.wallet.transactions.TransactionPeerPullCredit +import net.taler.wallet.transactions.TransactionState + +@Composable +fun ColumnScope.TransactionPeerPullCreditComposable(t: TransactionPeerPullCredit, spec: CurrencySpecification?) { + if (t.error == null) PeerQrCode( + state = t.txState, + talerUri = t.talerUri, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.receive_peer_amount_invoiced), + amount = t.amountRaw.withSpec(spec), + amountType = AmountType.Neutral, + ) + + val fee = t.amountRaw - t.amountEffective + if (!fee.isZero()) { + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(spec), + amountType = AmountType.Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_received), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Positive, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.send_peer_purpose), + info = t.info.summary ?: "", + ) +} + +@Preview +@Composable +fun TransactionPeerPullCreditPreview(loading: Boolean = false) { + val t = TransactionPeerPullCredit( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending, if (loading) CreatePurse else Ready), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.example.org/", + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + info = PeerInfoShort( + expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000), + summary = "test invoice", + ), + talerUri = "https://exchange.example.org/peer/pull/credit", + error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), + ) + Surface { + TransactionPeerComposable(t, true, null) {} + } +} + +@Preview +@Composable +fun TransactionPeerPullCreditLoadingPreview() { + TransactionPeerPullCreditPreview(loading = true) +}
\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt new file mode 100644 index 0000000..dadff4a --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.PeerInfoShort +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionPeerComposable +import net.taler.wallet.transactions.TransactionPeerPullDebit +import net.taler.wallet.transactions.TransactionState + +@Composable +fun TransactionPeerPullDebitComposable(t: TransactionPeerPullDebit, spec: CurrencySpecification?) { + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_order_total), + amount = t.amountRaw.withSpec(spec), + amountType = AmountType.Neutral, + ) + + val fee = t.amountEffective - t.amountRaw + if (!fee.isZero()) { + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(spec), + amountType = AmountType.Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_paid), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.send_peer_purpose), + info = t.info.summary ?: "", + ) +} + +@Preview +@Composable +fun TransactionPeerPullDebitPreview() { + val t = TransactionPeerPullDebit( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.example.org/", + amountRaw = Amount.fromString("TESTKUDOS", "42.1337"), + amountEffective = Amount.fromString("TESTKUDOS", "42.23"), + info = PeerInfoShort( + expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000), + summary = "test invoice", + ), + error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), + ) + Surface { + TransactionPeerComposable(t, true, null) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt new file mode 100644 index 0000000..dbf0fb9 --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.PeerInfoShort +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionPeerComposable +import net.taler.wallet.transactions.TransactionPeerPushCredit +import net.taler.wallet.transactions.TransactionState + +@Composable +fun TransactionPeerPushCreditComposable(t: TransactionPeerPushCredit, spec: CurrencySpecification?) { + TransactionAmountComposable( + label = stringResource(id = R.string.amount_sent), + amount = t.amountRaw.withSpec(spec), + amountType = AmountType.Neutral, + ) + + val fee = t.amountRaw - t.amountEffective + if (!fee.isZero()) { + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(spec), + amountType = AmountType.Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(id = R.string.amount_received), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Positive, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.send_peer_purpose), + info = t.info.summary ?: "", + ) +} + +@Preview +@Composable +fun TransactionPeerPushCreditPreview() { + val t = TransactionPeerPushCredit( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.example.org/", + amountRaw = Amount.fromString("TESTKUDOS", "42.23"), + amountEffective = Amount.fromString("TESTKUDOS", "42.1337"), + info = PeerInfoShort( + expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000), + summary = "test invoice", + ), + error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), + ) + Surface { + TransactionPeerComposable(t, true, null) {} + } +} diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt new file mode 100644 index 0000000..e592c3e --- /dev/null +++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt @@ -0,0 +1,152 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.peer + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.common.CurrencySpecification +import net.taler.common.Timestamp +import net.taler.wallet.R +import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.compose.QrCodeUriComposable +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.getQrCodeSize +import net.taler.wallet.transactions.AmountType +import net.taler.wallet.transactions.PeerInfoShort +import net.taler.wallet.transactions.TransactionAction.Abort +import net.taler.wallet.transactions.TransactionAction.Retry +import net.taler.wallet.transactions.TransactionAction.Suspend +import net.taler.wallet.transactions.TransactionAmountComposable +import net.taler.wallet.transactions.TransactionInfoComposable +import net.taler.wallet.transactions.TransactionMajorState.Pending +import net.taler.wallet.transactions.TransactionMinorState.CreatePurse +import net.taler.wallet.transactions.TransactionMinorState.Ready +import net.taler.wallet.transactions.TransactionPeerComposable +import net.taler.wallet.transactions.TransactionPeerPushDebit +import net.taler.wallet.transactions.TransactionState + +@Composable +fun ColumnScope.TransactionPeerPushDebitComposable(t: TransactionPeerPushDebit, spec: CurrencySpecification?) { + if (t.error == null) PeerQrCode( + state = t.txState, + talerUri = t.talerUri, + ) + + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_order_total), + amount = t.amountRaw.withSpec(spec), + amountType = AmountType.Neutral, + ) + + val fee = t.amountEffective - t.amountRaw + if (!fee.isZero()) { + TransactionAmountComposable( + label = stringResource(id = R.string.withdraw_fees), + amount = fee.withSpec(spec), + amountType = AmountType.Negative, + ) + } + + TransactionAmountComposable( + label = stringResource(id = R.string.transaction_paid), + amount = t.amountEffective.withSpec(spec), + amountType = AmountType.Negative, + ) + + TransactionInfoComposable( + label = stringResource(id = R.string.send_peer_purpose), + info = t.info.summary ?: "", + ) +} + +@Composable +fun ColumnScope.PeerQrCode(state: TransactionState, talerUri: String?) { + if (state == TransactionState(Pending)) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + style = MaterialTheme.typography.titleLarge, + text = stringResource(id = R.string.send_peer_payment_instruction), + textAlign = TextAlign.Center, + ) + + if (state.minor == Ready && talerUri != null) { + QrCodeUriComposable( + talerUri = talerUri, + clipBoardLabel = "Push payment", + buttonText = stringResource(id = R.string.copy), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.bodyLarge, + text = stringResource(id = R.string.receive_peer_invoice_uri), + ) + } + } else { + val qrCodeSize = getQrCodeSize() + CircularProgressIndicator( + modifier = Modifier + .padding(32.dp) + .size(qrCodeSize) + .align(CenterHorizontally), + ) + } + } + +} + +@Preview +@Composable +fun TransactionPeerPushDebitPreview(loading: Boolean = false) { + val t = TransactionPeerPushDebit( + transactionId = "transactionId", + timestamp = Timestamp.fromMillis(System.currentTimeMillis() - 360 * 60 * 1000), + txState = TransactionState(Pending, if (loading) CreatePurse else Ready), + txActions = listOf(Retry, Suspend, Abort), + exchangeBaseUrl = "https://exchange.example.org/", + amountRaw = Amount.fromString("TESTKUDOS", "42.1337"), + amountEffective = Amount.fromString("TESTKUDOS", "42.23"), + info = PeerInfoShort( + expiration = Timestamp.fromMillis(System.currentTimeMillis() + 60 * 60 * 1000), + summary = "test invoice", + ), + talerUri = "https://exchange.example.org/peer/pull/credit", + error = TalerErrorInfo(code = EXCHANGE_GENERIC_KYC_REQUIRED), + ) + + TalerSurface { + TransactionPeerComposable(t, true, null) {} + } +} + +@Preview +@Composable +fun TransactionPeerPushDebitLoadingPreview() { + TransactionPeerPushDebitPreview(loading = true) +}
\ No newline at end of file |