Updated on 2026-08-14
This commit is contained in:
parent
72d9b99348
commit
de3bdb3cb0
18 changed files with 138 additions and 49 deletions
|
|
@ -25,6 +25,7 @@ import com.tangem.core.ui.components.Keyboard
|
|||
import com.tangem.core.ui.components.buttons.common.TangemButton
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
|
||||
import com.tangem.core.ui.components.buttons.common.contentColor
|
||||
import com.tangem.core.ui.components.keyboardAsState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.isNullOrEmpty
|
||||
|
|
@ -73,12 +74,18 @@ fun NavigationPrimaryButton(primaryButton: NavigationButton?, modifier: Modifier
|
|||
} else {
|
||||
TangemButtonIconPosition.None
|
||||
}
|
||||
val color = if (button.isDimmed) {
|
||||
TangemButtonsDefaults.secondaryButtonColors
|
||||
.copy(contentColor = TangemTheme.colors.text.tertiary)
|
||||
} else {
|
||||
TangemButtonsDefaults.primaryButtonColors
|
||||
}
|
||||
TangemButton(
|
||||
text = button.textReference.resolveReference(),
|
||||
enabled = button.isEnabled,
|
||||
onClick = button.onClick,
|
||||
showProgress = button.showProgress,
|
||||
colors = TangemButtonsDefaults.primaryButtonColors,
|
||||
colors = color,
|
||||
textStyle = TangemTheme.typography.subtitle1,
|
||||
icon = icon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ sealed class NavigationButtonsState {
|
|||
) : NavigationButtonsState()
|
||||
}
|
||||
|
||||
/**
|
||||
* @property textReference text
|
||||
* @property iconRes icon resource id
|
||||
* @property isSecondary should set secondary color scheme
|
||||
* @property isIconVisible determines whether icon is visible
|
||||
* @property showProgress indicates progress state of button
|
||||
* @property isEnabled enabled
|
||||
* @property isDimmed determines whether the button content will be dimmed.
|
||||
* This property will be ignored if [isEnabled] is `false`.
|
||||
* @property onClick lambda be invoked when action component is clicked
|
||||
*/
|
||||
data class NavigationButton(
|
||||
val textReference: TextReference,
|
||||
@DrawableRes val iconRes: Int? = null,
|
||||
|
|
@ -23,5 +34,6 @@ data class NavigationButton(
|
|||
val isIconVisible: Boolean = false,
|
||||
val showProgress: Boolean = false,
|
||||
val isEnabled: Boolean = true,
|
||||
val isDimmed: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -861,6 +861,7 @@
|
|||
<string name="staking_rewards">Rewards</string>
|
||||
<string name="staking_stake_locked">Stake locked</string>
|
||||
<string name="staking_stake_more">Stake more</string>
|
||||
<string name="staking_stake_more_button_unavailability_reason">When staking %1$s, your entire %2$s balance is staked. Any additional %2$s you deposit to your Tangem Wallet will also be automatically staked.</string>
|
||||
<string name="staking_staked_amount">Staked amount</string>
|
||||
<string name="staking_summary_description_text">You stake %1$s and will be receiving your reward %2$s</string>
|
||||
<string name="staking_tap_to_unlock">Tap to unlock</string>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ internal class StakeKitErrorConverter(
|
|||
message = stakeKitErrorResponse.message,
|
||||
code = stakeKitErrorResponse.code,
|
||||
methodName = stakeKitErrorResponse.path,
|
||||
details = stakeKitErrorResponse.details?.let(StakeKitErrorDetailsConverter::convert),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
StakingError.StakeKitUnknownError(value)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.data.staking.converters.error
|
||||
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorDetailsDTO
|
||||
import com.tangem.domain.staking.model.stakekit.StakingError
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal object StakeKitErrorDetailsConverter :
|
||||
Converter<StakeKitErrorDetailsDTO, StakingError.StakeKitApiError.ErrorDetails> {
|
||||
|
||||
override fun convert(value: StakeKitErrorDetailsDTO): StakingError.StakeKitApiError.ErrorDetails {
|
||||
return StakingError.StakeKitApiError.ErrorDetails(
|
||||
amount = value.amount?.toBigDecimalOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package com.tangem.domain.staking.model.stakekit
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
sealed class StakingError {
|
||||
|
||||
// region stakekit errors
|
||||
|
|
@ -7,8 +9,14 @@ sealed class StakingError {
|
|||
data class StakeKitApiError(
|
||||
val message: String?,
|
||||
val code: Int?,
|
||||
val details: ErrorDetails?,
|
||||
val methodName: String?,
|
||||
) : StakingError()
|
||||
) : StakingError() {
|
||||
|
||||
data class ErrorDetails(
|
||||
val amount: BigDecimal?,
|
||||
)
|
||||
}
|
||||
|
||||
data class StakeKitUnknownError(
|
||||
val jsonString: String? = null,
|
||||
|
|
@ -17,4 +25,13 @@ sealed class StakingError {
|
|||
// endregion
|
||||
|
||||
data class DomainError(val message: String?) : StakingError()
|
||||
}
|
||||
|
||||
sealed class StakingErrors {
|
||||
|
||||
abstract val message: String
|
||||
|
||||
data object MinimumAmountNotReachedError : StakingErrors() {
|
||||
override val message: String = "MinimumAmountNotReachedError"
|
||||
}
|
||||
}
|
||||
|
|
@ -50,9 +50,8 @@ data class Yield(
|
|||
val isPartialAmountDisabled: Boolean
|
||||
get() {
|
||||
val enterAmount = args[ArgType.AMOUNT] ?: return false
|
||||
val min = enterAmount.minimum ?: return false
|
||||
val max = enterAmount.maximum ?: return false
|
||||
return min.signum() == -1 && max.signum() == -1
|
||||
return max.signum() == -1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,4 +65,6 @@ internal interface StakingClickIntents : AmountScreenClickIntents {
|
|||
fun onFailedTxEmailClick(errorMessage: String)
|
||||
|
||||
fun openTokenDetails(cryptoCurrency: CryptoCurrency)
|
||||
|
||||
fun showPrimaryClickAlert()
|
||||
}
|
||||
|
|
@ -295,13 +295,13 @@ internal class StakingModel @Inject constructor(
|
|||
)
|
||||
updateNotifications()
|
||||
},
|
||||
onStakingFeeError = {
|
||||
stateController.update(AddStakingErrorTransformer())
|
||||
updateNotifications(GetFeeError.UnknownError)
|
||||
onStakingFeeError = { stakingFeeError ->
|
||||
stateController.update(AddStakingErrorTransformer)
|
||||
updateNotifications(stakingError = stakingFeeError)
|
||||
},
|
||||
onFeeError = { error ->
|
||||
analyticsEventHandler.send(StakingAnalyticsEvent.TransactionError)
|
||||
stateController.update(AddStakingErrorTransformer())
|
||||
stateController.update(AddStakingErrorTransformer)
|
||||
updateNotifications(error)
|
||||
},
|
||||
onApprovalFee = { fee ->
|
||||
|
|
@ -646,7 +646,7 @@ internal class StakingModel @Inject constructor(
|
|||
}.saveIn(approvalJobHolder)
|
||||
}
|
||||
|
||||
private fun updateNotifications(feeError: GetFeeError? = null) {
|
||||
private fun updateNotifications(feeError: GetFeeError? = null, stakingError: StakingError? = null) {
|
||||
modelScope.launch {
|
||||
val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data
|
||||
val feeState = confirmationState?.feeState as? FeeState.Content
|
||||
|
|
@ -687,6 +687,7 @@ internal class StakingModel @Inject constructor(
|
|||
currencyCheck = currencyStatus,
|
||||
isSubtractAvailable = isAmountSubtractAvailable,
|
||||
feeError = feeError,
|
||||
stakingError = stakingError,
|
||||
yield = yield,
|
||||
),
|
||||
)
|
||||
|
|
@ -839,6 +840,14 @@ internal class StakingModel @Inject constructor(
|
|||
innerRouter.openTokenDetails(userWalletId, cryptoCurrency)
|
||||
}
|
||||
|
||||
override fun showPrimaryClickAlert() {
|
||||
stateController.updateEvent(
|
||||
StakingEvent.ShowAlert(
|
||||
StakingAlertUM.StakeMoreClickUnavailable(cryptoCurrencyStatus.currency),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun setupApprovalNeeded() {
|
||||
stakingApproval = isApproveNeededUseCase(cryptoCurrencyStatus.currency).fold(
|
||||
ifRight = { approval ->
|
||||
|
|
|
|||
|
|
@ -20,20 +20,20 @@ internal object StakingNotification {
|
|||
buttonState = buttonState,
|
||||
onCloseClick = onCloseClick,
|
||||
) {
|
||||
data class StakedPositionNotFoundError(val message: String) : StakingNotification.Error(
|
||||
data class StakedPositionNotFoundError(val message: String) : Error(
|
||||
title = stringReference(message),
|
||||
subtitle = stringReference(message),
|
||||
)
|
||||
|
||||
data class Common(val subtitle: TextReference) : StakingNotification.Error(
|
||||
data class Common(val subtitle: TextReference) : Error(
|
||||
title = resourceReference(R.string.common_error),
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
data class CardanoMinimumBalance(
|
||||
data class MinimumAmountNotReachedError(
|
||||
val title: TextReference,
|
||||
val subtitle: TextReference,
|
||||
) : StakingNotification.Error(
|
||||
) : Error(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
|
@ -54,9 +54,9 @@ internal object StakingNotification {
|
|||
data class TransactionInProgress(
|
||||
val title: TextReference,
|
||||
val description: TextReference,
|
||||
) : StakingNotification.Warning(title = title, subtitle = description)
|
||||
) : Warning(title = title, subtitle = description)
|
||||
|
||||
data object LowStakedBalance : StakingNotification.Warning(
|
||||
data object LowStakedBalance : Warning(
|
||||
title = resourceReference(R.string.staking_notification_low_staked_balance_title),
|
||||
subtitle = resourceReference(R.string.staking_notification_low_staked_balance_text),
|
||||
)
|
||||
|
|
@ -75,12 +75,12 @@ internal object StakingNotification {
|
|||
) {
|
||||
data class EarnRewards(
|
||||
val subtitleText: TextReference,
|
||||
) : StakingNotification.Info(
|
||||
) : Info(
|
||||
title = resourceReference(R.string.staking_notification_earn_rewards_title),
|
||||
subtitle = subtitleText,
|
||||
)
|
||||
|
||||
data object StakeEntireBalance : StakingNotification.Info(
|
||||
data object StakeEntireBalance : Info(
|
||||
title = resourceReference(R.string.common_network_fee_title),
|
||||
subtitle = resourceReference(R.string.staking_notification_stake_entire_balance_text),
|
||||
)
|
||||
|
|
@ -88,7 +88,7 @@ internal object StakingNotification {
|
|||
data class Unstake(
|
||||
val cooldownPeriodDays: Int,
|
||||
@StringRes val subtitleRes: Int,
|
||||
) : StakingNotification.Info(
|
||||
) : Info(
|
||||
title = resourceReference(R.string.common_unstake),
|
||||
subtitle = resourceReference(
|
||||
subtitleRes,
|
||||
|
|
@ -105,7 +105,7 @@ internal object StakingNotification {
|
|||
data class Ordinary(
|
||||
val title: TextReference,
|
||||
val text: TextReference,
|
||||
) : StakingNotification.Info(
|
||||
) : Info(
|
||||
title = title,
|
||||
subtitle = text,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.tangem.common.ui.alerts.models.AlertUM
|
|||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.features.staking.impl.R
|
||||
|
||||
@Immutable
|
||||
|
|
@ -48,4 +49,16 @@ internal sealed class StakingAlertUM : AlertUM {
|
|||
override val message: TextReference = resourceReference(id = R.string.staking_error_no_validators_message)
|
||||
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
|
||||
}
|
||||
|
||||
data class StakeMoreClickUnavailable(
|
||||
val cryptoCurrency: CryptoCurrency,
|
||||
) : StakingAlertUM() {
|
||||
override val onConfirmClick: (() -> Unit)? = null
|
||||
override val title: TextReference? = null
|
||||
override val message: TextReference = resourceReference(
|
||||
id = R.string.staking_stake_more_button_unavailability_reason,
|
||||
wrappedList(cryptoCurrency.name, cryptoCurrency.symbol),
|
||||
)
|
||||
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,4 +74,6 @@ internal object StakingClickIntentsStub : StakingClickIntents {
|
|||
override fun openTokenDetails(cryptoCurrency: CryptoCurrency) {}
|
||||
|
||||
override fun onActiveStakeAnalytic() {}
|
||||
|
||||
override fun showPrimaryClickAlert() {}
|
||||
}
|
||||
|
|
@ -1,39 +1,20 @@
|
|||
package com.tangem.features.staking.impl.presentation.state.transformers
|
||||
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.staking.model.stakekit.StakingError
|
||||
import com.tangem.features.staking.impl.presentation.state.FeeState
|
||||
import com.tangem.features.staking.impl.presentation.state.StakingNotification
|
||||
import com.tangem.features.staking.impl.presentation.state.StakingStates
|
||||
import com.tangem.features.staking.impl.presentation.state.StakingUiState
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
internal class AddStakingErrorTransformer(
|
||||
private val error: StakingError? = null,
|
||||
) : Transformer<StakingUiState> {
|
||||
internal object AddStakingErrorTransformer : Transformer<StakingUiState> {
|
||||
|
||||
override fun transform(prevState: StakingUiState): StakingUiState {
|
||||
val confirmationState =
|
||||
prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState
|
||||
|
||||
val notifications = buildList {
|
||||
addAll(confirmationState.notifications)
|
||||
error?.let { add(convertToNotification(it)) }
|
||||
}.toPersistentList()
|
||||
|
||||
return prevState.copy(
|
||||
confirmationState = confirmationState.copy(
|
||||
notifications = notifications,
|
||||
feeState = FeeState.Error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertToNotification(error: StakingError): NotificationUM {
|
||||
return StakingNotification.Error.Common(
|
||||
subtitle = stringReference(error.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,15 +47,22 @@ internal class SetButtonsStateTransformer(
|
|||
val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED
|
||||
|
||||
val isIconVisible = isConfirmation && !isCompleted
|
||||
val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled()
|
||||
return NavigationButton(
|
||||
textReference = prevState.getButtonText(),
|
||||
iconRes = R.drawable.ic_tangem_24,
|
||||
isSecondary = false,
|
||||
isDimmed = isPrimaryButtonDisabled,
|
||||
isIconVisible = isIconVisible,
|
||||
showProgress = isInProgress,
|
||||
isEnabled = prevState.isButtonEnabled(),
|
||||
onClick = { prevState.onPrimaryClick() },
|
||||
).takeIf { prevState.isPrimaryButtonVisible() }
|
||||
onClick = {
|
||||
if (isPrimaryButtonDisabled) {
|
||||
prevState.clickIntents.showPrimaryClickAlert()
|
||||
} else {
|
||||
prevState.onPrimaryClick()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPrevButton(prevState: StakingUiState): NavigationButton? {
|
||||
|
|
@ -186,12 +193,12 @@ internal class SetButtonsStateTransformer(
|
|||
-> true
|
||||
}
|
||||
|
||||
private fun StakingUiState.isPrimaryButtonVisible(): Boolean {
|
||||
private fun StakingUiState.isPrimaryButtonDisabled(): Boolean {
|
||||
val initialState = initialInfoState as? StakingStates.InitialInfoState.Data
|
||||
val hasNotStaking = initialState?.yieldBalance == InnerYieldBalanceState.Empty
|
||||
val isCardano = BlockchainUtils.isCardano(cryptoCurrencyBlockchainId)
|
||||
|
||||
return hasNotStaking || !(isCardano && currentStep == StakingStep.InitialInfo)
|
||||
return !hasNotStaking && isCardano && currentStep == StakingStep.InitialInfo
|
||||
}
|
||||
|
||||
private fun StakingUiState.isButtonEnabled(): Boolean {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ internal class AmountRequirementStateTransformer(
|
|||
val isExceedsMaxRequirement = if (maximum?.isPositive() == true) {
|
||||
maximum?.compareTo(amount) == -1
|
||||
} else {
|
||||
cryptoCurrencyStatus.value.amount?.compareTo(amount) == 1
|
||||
cryptoCurrencyStatus.value.amount?.compareTo(amount) == -1
|
||||
}
|
||||
|
||||
val errorText = when {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionN
|
|||
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
|
||||
import com.tangem.core.ui.extensions.networkIconResId
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.staking.model.stakekit.StakingError
|
||||
import com.tangem.domain.staking.model.stakekit.StakingErrors
|
||||
import com.tangem.domain.staking.model.stakekit.Yield
|
||||
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
|
|
@ -39,6 +42,7 @@ internal class AddStakingNotificationsTransformer(
|
|||
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
private val currencyWarning: CryptoCurrencyWarning?,
|
||||
private val feeError: GetFeeError?,
|
||||
private val stakingError: StakingError?,
|
||||
private val currencyCheck: CryptoCurrencyCheck,
|
||||
private val isSubtractAvailable: Boolean,
|
||||
private val yield: Yield,
|
||||
|
|
@ -93,6 +97,9 @@ internal class AddStakingNotificationsTransformer(
|
|||
onReload = prevState.clickIntents::getFee,
|
||||
feeValue = feeValue,
|
||||
)
|
||||
addStakingErrorNotifications(
|
||||
stakingError = stakingError,
|
||||
)
|
||||
// warnings
|
||||
addWarningNotifications(
|
||||
prevState = prevState,
|
||||
|
|
@ -128,6 +135,22 @@ internal class AddStakingNotificationsTransformer(
|
|||
)
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addStakingErrorNotifications(stakingError: StakingError?) {
|
||||
when (stakingError) {
|
||||
is StakingError.StakeKitApiError -> {
|
||||
if (stakingError.message != StakingErrors.MinimumAmountNotReachedError.message) {
|
||||
add(
|
||||
StakingNotification.Error.Common(subtitle = stringReference(stakingError.toString())),
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> Unit
|
||||
else -> add(
|
||||
StakingNotification.Error.Common(subtitle = stringReference(stakingError.toString())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addErrorNotifications(
|
||||
prevState: StakingUiState,
|
||||
onReload: () -> Unit,
|
||||
|
|
@ -193,7 +216,7 @@ internal class AddStakingNotificationsTransformer(
|
|||
) {
|
||||
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
|
||||
val appCurrency = appCurrencyProvider()
|
||||
val cryptoCurrency = cryptoCurrencyStatus.currency
|
||||
cryptoCurrencyStatus.currency
|
||||
|
||||
addRentExemptionNotification(
|
||||
rentWarning = currencyCheck.rentWarning,
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ internal class StakingInfoNotificationsFactory(
|
|||
val balance = cryptoCurrencyStatus.value.amount.orZero()
|
||||
if (isCardano && balance - feeValue < MINIMUM_STAKE_BALANCE) {
|
||||
add(
|
||||
StakingNotification.Error.CardanoMinimumBalance(
|
||||
StakingNotification.Error.MinimumAmountNotReachedError(
|
||||
title = resourceReference(R.string.staking_notification_minimum_balance_title),
|
||||
subtitle = resourceReference(R.string.staking_notification_minimum_stake_ada_text),
|
||||
),
|
||||
|
|
@ -189,7 +189,7 @@ internal class StakingInfoNotificationsFactory(
|
|||
val balance = cryptoCurrencyStatus.value.amount.orZero()
|
||||
if (isCardano && balance - feeValue < MINIMUM_RESTAKE_BALANCE) {
|
||||
add(
|
||||
StakingNotification.Error.CardanoMinimumBalance(
|
||||
StakingNotification.Error.MinimumAmountNotReachedError(
|
||||
title = resourceReference(R.string.staking_notification_minimum_restake_ada_title),
|
||||
subtitle = resourceReference(R.string.staking_notification_minimum_restake_ada_text),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ internal fun StakingActionType?.getPendingActionTitle(): TextReference = when (t
|
|||
StakingActionType.REVOTE -> resourceReference(R.string.staking_revote)
|
||||
StakingActionType.REBOND -> resourceReference(R.string.staking_rebond)
|
||||
StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate)
|
||||
StakingActionType.STAKE -> resourceReference(R.string.common_stake)
|
||||
StakingActionType.STAKE -> resourceReference(R.string.staking_restake)
|
||||
StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake)
|
||||
StakingActionType.UNKNOWN -> TextReference.EMPTY
|
||||
null -> TextReference.EMPTY
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue