SumUpOption.kt
/*
* This file is part of GNU Taler
* (C) 2026 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.merchantpos.paymentoptions.sumup
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.sumup.merchant.reader.api.SumUpAPI
import com.sumup.merchant.reader.api.SumUpCardReaderPageContract
import com.sumup.merchant.reader.api.SumUpCheckoutContract
import com.sumup.merchant.reader.api.SumUpLogin
import com.sumup.merchant.reader.api.SumUpLoginContract
import com.sumup.merchant.reader.api.SumUpPayment
import com.sumup.merchant.reader.models.SavedCardReaderDetailsResult.SavedCardReaderDetails
import net.taler.common.Amount
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.R
import net.taler.merchantpos.paymentoptions.ChargeResult
import net.taler.merchantpos.paymentoptions.ExternalCharge
import net.taler.merchantpos.paymentoptions.PaymentDevice
import net.taler.merchantpos.paymentoptions.PaymentOption
import net.taler.merchantpos.paymentoptions.PaymentOptionActions
import net.taler.merchantpos.paymentoptions.PaymentOptionState
import net.taler.merchantpos.paymentoptions.PaymentOptionStatus
import net.taler.merchantpos.paymentoptions.PaymentOptionsManager
import net.taler.merchantpos.paymentoptions.RefundOutcome
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal interface ReaderConnection
{
/** Whether the reader SDK has a signed-in merchant session. */
fun isLoggedIn(): Boolean
/** Merchant code of the SumUp account signed in on this device, if any. */
fun merchant(): String?
/** Hands the SDK a freshly minted access token for the session it already holds. */
fun updateAccessToken(token: String)
/** Saved readers with their last known device details and current connection state. */
fun devices(): List<PaymentDevice>
/** Clears the reader settings and signs out of the SDK. */
fun logout()
}
/**
* Per-installation SumUp settings.
*
* The affiliate key identifies the *application* to SumUp, not the merchant: every
* merchant signs into their own SumUp account on their own device. A key built into
* the app is therefore the normal case, but a merchant running their own SumUp
* partner registration can override it here, and a build that ships without a key
* stays usable.
*/
internal class SumUpSettings(
context: Context,
/** Key this build ships with; injected so behaviour does not depend on the build environment. */
private val buildKey: String = BuildConfig.SUMUP_AFFILIATE_KEY,
)
{
private val preferences = context.getSharedPreferences("payment-terminals", Context.MODE_PRIVATE)
fun affiliateKey(): String = storedKey() ?: buildKey
fun hasCustomKey(): Boolean = storedKey() != null
fun setAffiliateKey(key: String)
{
val trimmed = key.trim()
if (trimmed.isEmpty())
{
preferences.edit().remove(KEY_AFFILIATE).apply()
}
else
{
preferences.edit().putString(KEY_AFFILIATE, trimmed).apply()
}
}
private fun storedKey() = preferences.getString(KEY_AFFILIATE, null)?.takeIf { it.isNotBlank() }
private companion object
{
const val KEY_AFFILIATE = "provider.sumup.affiliate-key"
}
}
private class SumUpConnection : ReaderConnection
{
override fun isLoggedIn(): Boolean = SumUpAPI.isLoggedIn()
override fun merchant(): String? = SumUpAPI.getCurrentMerchant()?.merchantCode
override fun updateAccessToken(token: String) = SumUpAPI.updateAccessToken(token)
override fun devices(): List<PaymentDevice>
{
val reader = SumUpAPI.getSavedCardReaderDetails() as? SavedCardReaderDetails ?: return emptyList()
val serial = reader.serialNumber
return listOf(
PaymentDevice(
id = serial ?: SUMUP_ID,
name = reader.readerType?.name?.replace('_', ' '),
serialNumber = serial,
batteryPercent = reader.lastKnownBatteryPercentage,
connected = SumUpAPI.isCardReaderConnected(),
),
)
}
override fun logout()
{
SumUpAPI.clearCardTerminalSettings()
SumUpAPI.logout()
}
}
internal const val SUMUP_ID = "sumup"
class SumUpOption internal constructor(
private val context: Context,
private val connection: ReaderConnection,
private val settings: SumUpSettings,
internal val auth: SumUpAuth,
private val rest: SumUpRestApi = SumUpHttpRestApi(),
) : PaymentOption
{
constructor(context: Context) : this(
context.applicationContext,
SumUpConnection(),
SumUpSettings(context.applicationContext),
SumUpAuth(context.applicationContext),
)
private var transparentLoginTried = false
private var host: Host? = null
private var pendingCharge: ((ChargeResult) -> Unit)? = null
override val id = SUMUP_ID
override val nameRes = R.string.terminal_sumup
override val descriptionRes = R.string.terminal_sumup_description
override val hasSettings = true
override val tracksConnection = true
override val chargesExternally = true
override val activationNoticeRes = R.string.terminal_sumup_activation_notice
/** Refunds require the REST API and the separately approved `payments` scope. */
override val canRefund = false
override val icon: ImageVector
@Composable get() = Icons.Default.CreditCard
override fun isConfigured() = settings.affiliateKey().isNotBlank()
override fun probe(): PaymentOptionState
{
if (!isConfigured()) return PaymentOptionState(id, status = PaymentOptionStatus.NotConfigured)
if (!connection.isLoggedIn()) return PaymentOptionState(id, status = PaymentOptionStatus.SignInRequired)
val account = connection.merchant()
if (!hasBluetoothPermission())
{
return PaymentOptionState(id, status = PaymentOptionStatus.PermissionRequired, account = account)
}
val devices = connection.devices()
return PaymentOptionState(
id = id,
status =
when
{
devices.any { it.connected } -> PaymentOptionStatus.Connected
devices.isEmpty() -> PaymentOptionStatus.NotPaired
else -> PaymentOptionStatus.Disconnected
},
account = account,
devices = devices,
)
}
private fun hasBluetoothPermission(): Boolean =
Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) == PackageManager.PERMISSION_GRANTED
/**
* Renews the access token before it expires and updates the SDK session.
* Revoked grants still require the merchant to authorize again.
*/
override suspend fun maintainSession()
{
if (!isConfigured() ||
!auth.isAuthorized())
{
return
}
auth.refreshIfNeeded()
val token = auth.validAccessToken() ?: return
if (connection.isLoggedIn())
{
// Already signed in: swapping the token in place keeps the session alive.
runCatching { connection.updateAccessToken(token) }
}
else
{
// Signed out: SumUp accepts the token instead of credentials, but that is
// an activity launch, so only try it once per activity.
requestTransparentLogin(token)
}
}
override suspend fun handleRedirect(uri: Uri): Boolean
{
if (uri.scheme != "taler-pos" ||
uri.host != SUMUP_REDIRECT_HOST)
{
return false
}
if (!auth.consumeAuthorizationState(uri.getQueryParameter("state"))) return true
val code = uri.getQueryParameter("code")
if (code.isNullOrBlank()) return true
auth.exchangeCode(code)
auth.validAccessToken()?.let { token ->
if (!connection.isLoggedIn())
{
transparentLoginTried = false
requestTransparentLogin(token)
}
}
return true
}
private fun requestTransparentLogin(token: String)
{
if (transparentLoginTried) return
val host = host ?: return
transparentLoginTried = true
runCatching { host.openLogin(token) }
}
override fun onLogout()
{
auth.clearAll()
connection.logout()
}
// Activity result plumbing, rebound on every activity creation.
private class Host(
val activity: ComponentActivity,
val manager: PaymentOptionsManager,
val openReaderPage: () -> Unit,
val openLogin: (String?) -> Unit,
val openCheckout: (SumUpPayment) -> Unit,
)
override fun registerLaunchers(
activity: ComponentActivity,
manager: PaymentOptionsManager,
)
{
val readerLauncher =
activity.registerForActivityResult(SumUpCardReaderPageContract()) { result ->
val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0
if (code != 0 &&
code != SumUpAPI.Response.ResultCode.SUCCESSFUL)
{
manager.reportActionFailure()
}
manager.refresh()
}
val loginLauncher =
activity.registerForActivityResult(SumUpLoginContract()) { result ->
val code = result.data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0
if (code == SumUpAPI.Response.ResultCode.SUCCESSFUL)
{
launchSetup()
}
else if (code != 0)
{
manager.reportActionFailure()
}
manager.refresh()
}
val checkoutLauncher =
activity.registerForActivityResult(SumUpCheckoutContract()) { result ->
val data = result.data
val outcome =
when (data?.getIntExtra(SumUpAPI.Response.RESULT_CODE, 0) ?: 0)
{
SumUpAPI.Response.ResultCode.SUCCESSFUL ->
{
ChargeResult.Success(data?.getStringExtra(SumUpAPI.Response.TX_CODE))
}
// Neither can be booked: the reference may or may not have been charged.
SumUpAPI.Response.ResultCode.ERROR_UNKNOWN_TRANSACTION_STATUS,
SumUpAPI.Response.ResultCode.ERROR_DUPLICATE_FOREIGN_TX_ID,
->
{
ChargeResult.Unknown(data?.getStringExtra(SumUpAPI.Response.MESSAGE))
}
0 ->
{
ChargeResult.Cancelled
}
else ->
{
ChargeResult.Failed(data?.getStringExtra(SumUpAPI.Response.MESSAGE))
}
}
val pending = pendingCharge
pendingCharge = null
pending?.invoke(outcome)
manager.refresh()
}
transparentLoginTried = false
host =
Host(
activity = activity,
manager = manager,
openReaderPage = { readerLauncher.launch(null) },
openLogin = { token ->
val login =
SumUpLogin
.builder(settings.affiliateKey())
.let { if (token != null) it.accessToken(token) else it }
.build()
loginLauncher.launch(login)
},
openCheckout = { payment -> checkoutLauncher.launch(payment) },
)
activity.lifecycle.addObserver(
object : DefaultLifecycleObserver
{
override fun onDestroy(owner: LifecycleOwner)
{
if (host?.activity === activity) host = null
}
},
)
}
/**
* Refunds through SumUp's REST API: the reader SDK cannot do it. That needs an
* authorised device, since SumUp refuses refunds on client credentials tokens.
*/
override suspend fun refund(
transactionCode: String?,
amount: Amount,
reason: String,
): RefundOutcome
{
if (transactionCode.isNullOrBlank())
{
return RefundOutcome.Failed("This payment has no SumUp transaction to refund")
}
auth.refreshIfNeeded()
val token =
auth.validAccessToken()
?: return RefundOutcome.Failed("Authorise this device with SumUp before refunding")
val merchantCode =
runCatching { connection.merchant() }.getOrNull()
?: return RefundOutcome.Failed("Sign in to SumUp before refunding")
val transactionId =
when (val found = rest.transactionId(merchantCode, transactionCode, token))
{
is RestResult.Success -> found.value
is RestResult.Failure -> return RefundOutcome.Failed(found.message)
}
return when (val refunded = rest.refund(merchantCode, transactionId, amount, token))
{
is RestResult.Success -> RefundOutcome.Success
is RestResult.Failure -> RefundOutcome.Failed(refunded.message)
}
}
override fun startCharge(
charge: ExternalCharge,
onResult: (ChargeResult) -> Unit,
): Boolean
{
val host = host ?: return false
if (pendingCharge != null ||
!isConfigured())
{
return false
}
val currency =
runCatching { SumUpPayment.Currency.valueOf(charge.amount.currency) }.getOrNull()
?: return false
// SumUp refuses more than two decimals; a finer amount cannot be charged at all.
val total =
runCatching {
BigDecimal
.valueOf(charge.amount.value)
.add(BigDecimal.valueOf(charge.amount.fraction.toLong(), 8))
.setScale(2, RoundingMode.UNNECESSARY)
}.getOrNull() ?: return false
if (!runCatching { connection.isLoggedIn() }.getOrDefault(false)) return false
val payment =
SumUpPayment
.builder()
.total(total)
.currency(currency)
.title(charge.title)
.foreignTransactionId(charge.reference)
.skipSuccessScreen()
.build()
pendingCharge = onResult
return try
{
host.openCheckout(payment)
true
}
catch (_: RuntimeException)
{
pendingCharge = null
false
}
}
/** Signs in when needed, then opens SumUp's own reader setup. */
fun launchSetup()
{
val host = host ?: return
host.manager.refresh()
if (!host.manager.state(id).enabled ||
!isConfigured())
{
return
}
host.manager.dismissError()
try
{
if (connection.isLoggedIn())
{
host.openReaderPage()
}
else
{
host.openLogin(auth.validAccessToken())
}
}
catch (_: RuntimeException)
{
host.manager.reportActionFailure()
}
}
@Composable
override fun DetailContent(
state: PaymentOptionState,
actions: PaymentOptionActions,
)
{
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (state.enabled &&
state.status != PaymentOptionStatus.NotConfigured)
{
Button(
onClick = ::launchSetup,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Text(
stringResource(
when (state.status)
{
PaymentOptionStatus.SignInRequired ->
{
R.string.terminal_sumup_sign_in
}
else ->
{
if (state.devices.isEmpty())
{
R.string.terminal_pair
}
else
{
R.string.terminal_manage
}
}
},
),
)
}
if (state.status != PaymentOptionStatus.SignInRequired)
{
OutlinedButton(
onClick = {
signOut(actions)
actions.refresh()
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_sign_out))
}
}
}
UnattendedSession(actions)
AdvancedSettings(actions)
}
}
/** Displays token expiry and refresh status. */
@Composable
private fun UnattendedSession(actions: PaymentOptionActions)
{
val authState by auth.state.observeAsState(SumUpAuthState())
val scope = rememberCoroutineScope()
val context = LocalContext.current
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true)
{
now = System.currentTimeMillis()
delay(1_000)
}
}
Text(
text = stringResource(R.string.terminal_sumup_session_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!authState.hasClientCredentials)
{
Text(
text = stringResource(R.string.terminal_sumup_session_no_client),
style = MaterialTheme.typography.bodyMedium,
)
return
}
if (!authState.authorized)
{
Text(
text = stringResource(R.string.terminal_sumup_session_not_authorized),
style = MaterialTheme.typography.bodyMedium,
)
}
else
{
val remaining = ((authState.expiresAt - now) / 1000).coerceAtLeast(0)
Text(
text =
stringResource(
R.string.terminal_sumup_session_expires,
"%d:%02d".format(remaining / 60, remaining % 60),
),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text =
stringResource(
R.string.terminal_sumup_session_refreshes,
authState.refreshCount,
if (authState.lastRefresh == 0L) "-" else timeOfDay(authState.lastRefresh),
),
style = MaterialTheme.typography.bodyMedium,
)
}
authState.lastError?.let {
Text(
text = stringResource(R.string.terminal_sumup_session_error, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(auth.authorizeUrl())),
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Text(
stringResource(
if (authState.authorized)
{
R.string.terminal_sumup_reauthorize
}
else
{
R.string.terminal_sumup_authorize
},
),
)
}
if (authState.authorized)
{
// Forces the refresh the app would otherwise do on its own, so the counter
// and the countdown above can be watched moving without waiting.
OutlinedButton(
onClick = {
scope.launch {
auth.refreshIfNeeded(force = true)
auth.validAccessToken()?.let { token ->
runCatching { connection.updateAccessToken(token) }
}
actions.refresh()
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_refresh_now))
}
OutlinedButton(
onClick = {
auth.clearAuthorization()
actions.refresh()
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_revoke))
}
}
}
private fun timeOfDay(millis: Long): String = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(millis))
/**
* The affiliate key identifies the app rather than the merchant, and the OAuth
* client belongs to the merchant. Neither is ever displayed: this only allows
* replacing them.
*/
@Composable
private fun AdvancedSettings(actions: PaymentOptionActions)
{
var expanded by rememberSaveable { mutableStateOf(false) }
var key by rememberSaveable { mutableStateOf("") }
var revealed by rememberSaveable { mutableStateOf(false) }
// Held as state: refreshing may leave the option's status unchanged, which
// would not on its own recompose this section after the key is replaced.
var custom by rememberSaveable { mutableStateOf(settings.hasCustomKey()) }
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.terminal_sumup_advanced), modifier = Modifier.weight(1f))
}
if (!expanded) return
Text(
text = stringResource(R.string.terminal_sumup_advanced_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text =
stringResource(
if (custom)
{
R.string.terminal_sumup_key_custom
}
else
{
R.string.terminal_sumup_key_builtin
},
),
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
label = { Text(stringResource(R.string.terminal_sumup_key_hint)) },
visualTransformation =
if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { revealed = !revealed }) {
Icon(
imageVector = if (revealed) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription =
stringResource(
if (revealed)
{
R.string.terminal_sumup_key_hide
}
else
{
R.string.terminal_sumup_key_show
},
),
)
}
},
)
Text(
text = stringResource(R.string.terminal_sumup_key_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = {
// The session belongs to the previous application identity, so drop it.
signOut(actions)
settings.setAffiliateKey(key)
custom = settings.hasCustomKey()
key = ""
revealed = false
actions.refresh()
},
enabled = key.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_save_key))
}
if (custom)
{
OutlinedButton(
onClick = {
signOut(actions)
settings.setAffiliateKey("")
custom = false
key = ""
actions.refresh()
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_reset_key))
}
}
OAuthClientFields()
}
/**
* The merchant's own OAuth application, registered in their SumUp dashboard. Its
* secret is what lets this installation renew the session without a person.
*/
@Composable
private fun OAuthClientFields()
{
var clientId by rememberSaveable { mutableStateOf(auth.clientId) }
var clientSecret by rememberSaveable { mutableStateOf("") }
var saved by rememberSaveable { mutableStateOf(auth.hasClientCredentials()) }
Text(
text = stringResource(R.string.terminal_sumup_oauth_help, SUMUP_REDIRECT_URI),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text =
stringResource(
if (saved) R.string.terminal_sumup_oauth_set else R.string.terminal_sumup_oauth_unset,
),
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = clientId,
onValueChange = { clientId = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
label = { Text(stringResource(R.string.terminal_sumup_client_id)) },
)
OutlinedTextField(
value = clientSecret,
onValueChange = { clientSecret = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
label = { Text(stringResource(R.string.terminal_sumup_client_secret)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedButton(
onClick = {
auth.setClientCredentials(clientId, clientSecret)
clientSecret = ""
saved = auth.hasClientCredentials()
},
enabled = clientId.isNotBlank() && clientSecret.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Text(stringResource(R.string.terminal_sumup_save_client))
}
}
private fun signOut(actions: PaymentOptionActions)
{
try
{
connection.logout()
}
catch (_: RuntimeException)
{
actions.reportFailure()
}
}
}