Updated on 2026-08-14

This commit is contained in:
Tangem 2022-04-12 16:51:32 +03:00
parent 4fc058cd2d
commit a73558501c
16 changed files with 849 additions and 96 deletions

View file

@ -1,71 +1,115 @@
package com.tangem.tap.common.compose
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.material.Button
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.tap.common.compose.extensions.stringResourceDefault
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
private class Button {}
@Composable
fun Button(
modifier: Modifier = Modifier,
text: String = "",
textId: Int? = null,
isEnabled: Boolean = true,
modifier: Modifier = Modifier,
leftContent: @Composable RowScope.() -> Unit = {},
rightContent: @Composable RowScope.() -> Unit = {},
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
leadingView: @Composable RowScope.() -> Unit = {},
middleView: @Composable RowScope.() -> Unit = { TextInButton(text = text, textId = textId) },
trailingView: @Composable RowScope.() -> Unit = {},
onClick: () -> Unit,
) {
Button(
modifier = modifier.height(42.dp),
modifier = modifier,
contentPadding = contentPadding,
enabled = isEnabled,
onClick = onClick,
) {
leftContent()
ButtonText(text = textId?.let { stringResource(id = it) } ?: text)
rightContent()
leadingView()
middleView()
trailingView()
}
}
@Composable
fun ButtonText(
text: String,
modifier: Modifier = Modifier
private fun TextInButton(
modifier: Modifier = Modifier,
text: String = "",
textId: Int? = null,
) {
Text(
text,
modifier = modifier,
text = stringResourceDefault(textId, text),
maxLines = 1,
style = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
)
}
@Composable
fun PasteButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
dpSize: DpSize = DpSize(40.dp, 40.dp),
onClick: () -> Unit,
content: @Composable (() -> Unit)? = null
) {
IconButton(
modifier = modifier.size(dpSize),
enabled = enabled,
onClick = onClick,
) {
when (content) {
null -> {
val icon = if (enabled) R.drawable.ic_paste else R.drawable.ic_paste_disabled
Icon(painterResource(id = icon), contentDescription = "Paste")
}
else -> content()
}
}
}
@Preview
@Composable
fun ButtonTest() {
Scaffold {
Button(
"Some button",
onClick = {}
)
Scaffold(
) {
Column(modifier = Modifier.padding(16.dp)) {
PreviewItem("Button") {
Button(text = "Some button") {}
}
PreviewItem("PasteButton") {
PasteButton(onClick = {})
}
}
}
}
@Composable
fun PreviewItem(
name: String,
content: @Composable RowScope.() -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier.weight(1f),
text = name,
)
content()
}
SpacerH8()
}

View file

@ -1,26 +1,24 @@
package com.tangem.tap.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.tangem.domain.common.ValueDebouncer
import com.tangem.domain.common.util.ValueDebouncer
/**
[REDACTED_AUTHOR]
* This is an empty compose view. It just remember the ValueDebouncer inside of itself.
*/
@Composable
fun ComposableTextDebouncer(
text: String,
fun <T> valueDebouncerAsState(
debounce: Long = 400,
onTextChanged: (String) -> Unit
): ValueDebouncer<String> {
val rTextDebounce = remember {
mutableStateOf(ValueDebouncer(text, debounce) { changedValue ->
changedValue?.let { onTextChanged(it) }
})
onValueChanged: (T) -> Unit
): ValueDebouncer<T> {
return remember {
ValueDebouncer(
debounce = debounce,
onValueChanged = { changedValue ->
changedValue?.let { onValueChanged(it) }
},
)
}
rTextDebounce.value.value = text
return rTextDebounce.value
}

View file

@ -0,0 +1,125 @@
package com.tangem.tap.common.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.tangem.domain.DomainDialog
import com.tangem.domain.DomainStateDialog
import com.tangem.domain.redux.domainStore
import com.tangem.domain.redux.global.DomainGlobalAction
import com.tangem.domain.redux.global.DomainGlobalState
import org.rekotlin.StoreSubscriber
@Composable
fun ComposeDialogManager() {
val dialogSate = remember { mutableStateOf<DomainStateDialog?>(null) }
val subscriber = remember {
object : StoreSubscriber<DomainGlobalState> {
override fun newState(state: DomainGlobalState) {
dialogSate.value = state.dialog
}
}
}
ShowTheDialog(dialogSate)
LaunchedEffect(key1 = Unit, block = {
domainStore.subscribe(subscriber) { state ->
state.skipRepeats { oldState, newState ->
oldState.globalState == newState.globalState
}.select { it.globalState }
}
})
DisposableEffect(key1 = Unit, effect = {
onDispose { domainStore.unsubscribe(subscriber) }
})
}
@Composable
fun ShowTheDialog(dialogState: MutableState<DomainStateDialog?>) {
if (dialogState.value == null) return
val onDismissRequest = { domainStore.dispatch(DomainGlobalAction.ShowDialog(null)) }
when (val dialog = dialogState.value) {
is DomainDialog.SelectTokenDialog -> {
SimpleDialog(
title = "Select a token",
items = dialog.items,
itemNameConverter = dialog.itemNameConverter,
onSelect = dialog.onSelect,
onDismissRequest = onDismissRequest
)
}
}
}
/**
* Dialog with single item selection
*/
@Composable
fun <T> SimpleDialog(
title: String,
items: List<T>,
itemNameConverter: (T) -> String,
onSelect: (T) -> Unit,
onDismissRequest: () -> Unit
) {
Dialog(
properties = DialogProperties(false, false),
onDismissRequest = { }
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier.padding(22.dp)
) {
Text(
text = title,
style = LocalTextStyle.provides(
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
).value
)
SpacerH16()
LazyColumn() {
items(items) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable {
onSelect(item)
onDismissRequest()
},
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = itemNameConverter(item),
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.tap.common.compose
import android.graphics.Rect
import android.view.ViewTreeObserver
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView
/**
[REDACTED_AUTHOR]
*/
@Composable
fun keyboardObserverAsState(): State<Keyboard> {
val keyboardState: MutableState<Keyboard> = remember { mutableStateOf(Keyboard.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened(keypadHeight)
} else {
Keyboard.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
sealed class Keyboard {
data class Opened(val height: Int) : Keyboard()
object Closed : Keyboard()
}

View file

@ -1,11 +1,16 @@
package com.tangem.tap.common.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
/**
[REDACTED_AUTHOR]
@ -22,4 +27,15 @@ fun ErrorView(
modifier = modifier,
style = style
)
}
@Preview
@Composable
fun ErrorViewTest() {
Scaffold(
) {
Box(Modifier.padding(16.dp)) {
ErrorView(text = "Some error description")
}
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.tap.common.compose
import androidx.compose.runtime.Composable
import timber.log.Timber
/**
* Simple logger for all recompositions
*/
@Composable
fun LogSideEffect(message: String) {
Timber.d("SideEffect: $message")
}

View file

@ -8,24 +8,31 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.extensions.ValueCallback
/**
[REDACTED_AUTHOR]
*/
private class OutlinedSpinner
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> OutlinedSpinner(
modifier: Modifier = Modifier,
title: String,
itemList: List<T>,
selectedItem: T,
selectedItem: Field.Data<T>,
onItemSelected: ValueCallback<T>,
modifier: Modifier = Modifier,
itemNameConverter: (T) -> String = { it.toString() },
isEnabled: Boolean = true,
onClose: VoidCallback = {}
) {
val rSelectedItem = remember { mutableStateOf(selectedItem) }
val rIsExpanded = remember { mutableStateOf(false) }
val rSelectedItem = remember { mutableStateOf(selectedItem.value) }
if (!selectedItem.isUserInput) {
rSelectedItem.value = selectedItem.value
}
val onItemSelectedInternal: (T) -> Unit = {
rSelectedItem.value = it
@ -44,6 +51,7 @@ fun <T> OutlinedSpinner(
OutlinedTextField(
modifier = modifier,
readOnly = true,
enabled = isEnabled,
value = itemNameConverter(rSelectedItem.value),
onValueChange = {},
label = { Text(title) },
@ -66,12 +74,12 @@ fun <T> OutlinedSpinner(
@Preview
@Composable
fun TestSpinnerPreview(){
fun TestSpinnerPreview() {
Scaffold() {
OutlinedSpinner(
title = "Blockchain name",
itemList = listOf(Blockchain.values()),
selectedItem = Blockchain.Avalanche,
selectedItem = Field.Data(Blockchain.Avalanche),
onItemSelected = {},
)
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
@ -14,14 +15,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.domain.common.DomainError
import com.tangem.domain.common.ErrorConverter
import com.tangem.domain.DomainError
import com.tangem.domain.ErrorConverter
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.compose.extensions.stringResourceDefault
/**
[REDACTED_AUTHOR]
@ -30,8 +32,8 @@ private class OutlinedTextFieldWidget
@Composable
fun OutlinedTextFieldWidget(
text: String,
modifier: Modifier = Modifier,
textFieldData: Field.Data<String>,
labelId: Int? = null,
label: String = "",
placeholderId: Int? = null,
@ -44,62 +46,66 @@ fun OutlinedTextFieldWidget(
errorConverter: ErrorConverter<String>? = null,
debounceTextChanges: Long = 400,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onTextChanged: (String) -> Unit,
) {
if (!isVisible) return
val placeholder = placeholderId?.let { stringResource(id = it) } ?: placeholder
val label = labelId?.let { stringResource(id = it) } ?: label
Column(
modifier = modifier.animateContentSize(),
) {
OutlinedProgressTextField(
text = text,
modifier = modifier,
label = label,
placeholder = placeholder,
textFieldData = textFieldData,
label = stringResourceDefault(labelId, label),
placeholder = stringResourceDefault(placeholderId, placeholder),
trailingIcon = trailingIcon,
isEnabled = isEnabled,
isLoading = isLoading,
error = error,
debounceTextChanges = debounceTextChanges,
debounce = debounceTextChanges,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
onTextChanged = onTextChanged
)
errorConverter?.let { TextFieldErrorWidget(error, it) }
errorConverter?.let { AnimatedErrorView(error, it) }
}
}
@Composable
private fun OutlinedProgressTextField(
text: String,
modifier: Modifier = Modifier,
textFieldData: Field.Data<String>,
label: String = "",
placeholder: String = "",
isEnabled: Boolean = true,
isLoading: Boolean = false,
error: DomainError? = null,
debounceTextChanges: Long = 400,
debounce: Long = 400,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
trailingIcon: @Composable (() -> Unit)? = null,
onTextChanged: (String) -> Unit,
) {
val rTextValue = remember { mutableStateOf(text) }
val textDebouncer = ComposableTextDebouncer(text, debounceTextChanges, onTextChanged)
val rTextDebouncer = valueDebouncerAsState(debounce, onTextChanged)
val rText = remember { mutableStateOf(textFieldData.value) }
// add ability to paste text from state
if (rTextValue.value != text) rTextValue.value = text
fun updateFieldValueAndEmmit(value: String){
rText.value = value
rTextDebouncer.emmit(value)
}
// This action came from redux. Update the field value and send a new event as if from the user
if (!textFieldData.isUserInput) {
updateFieldValueAndEmmit(textFieldData.value)
}
Box {
OutlinedTextField(
value = rTextValue.value,
onValueChange = {
// immediately change text for the OutlinedTextField
rTextValue.value = it
textDebouncer.emmit(it)
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth(),
value = rText.value,
onValueChange = ::updateFieldValueAndEmmit,
keyboardOptions = keyboardOptions,
label = { Text(label) },
placeholder = { Text(placeholder) },
trailingIcon = trailingIcon,
@ -120,7 +126,7 @@ private fun OutlinedProgressTextField(
}
@Composable
private fun TextFieldErrorWidget(
private fun AnimatedErrorView(
error: DomainError? = null,
errorConverter: ErrorConverter<String>,
) {
@ -162,41 +168,37 @@ fun OutlinedTextFieldWithErrorTest() {
) {
OutlinedTextFieldWidget(
modifier = modifier,
text = "",
textFieldData = Field.Data(""),
label = "First label",
placeholder = "1 placeholder",
error = null,
errorConverter = converter,
onTextChanged = {},
)
) {}
OutlinedTextFieldWidget(
modifier = modifier,
text = "First",
textFieldData = Field.Data("First"),
label = "First label",
placeholder = "1 placeholder",
error = null,
errorConverter = converter,
onTextChanged = {},
)
) {}
OutlinedTextFieldWidget(
modifier = modifier,
text = "First",
textFieldData = Field.Data("First"),
label = "First label",
placeholder = "1 placeholder",
isLoading = true,
error = null,
errorConverter = converter,
onTextChanged = {},
)
) {}
OutlinedTextFieldWidget(
modifier = modifier,
text = "First",
textFieldData = Field.Data("First"),
label = "First label",
placeholder = "1 placeholder",
error = SimpleError(),
errorConverter = converter,
onTextChanged = {},
)
) {}
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.tap.common.extensions.compose
package com.tangem.tap.common.compose.extensions
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb

View file

@ -0,0 +1,21 @@
package com.tangem.tap.common.compose.extensions
import android.content.res.Resources
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
[REDACTED_AUTHOR]
*/
@Composable
fun stringResourceDefault(@StringRes id: Int?, default: String = ""): String {
val resources = LocalContext.current.resources
return try {
resources.getString(requireNotNull(id))
} catch (ex: Resources.NotFoundException) {
default
} catch (ex: IllegalArgumentException) {
default
}
}

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import com.tangem.tangem_sdk_new.extensions.dpToPx
import com.tangem.tap.common.compose.SpacerS16
import com.tangem.tap.common.compose.SpacerS24
import com.tangem.tap.common.extensions.compose.toAndroidGraphicsColor
import com.tangem.tap.common.compose.extensions.toAndroidGraphicsColor
import com.tangem.wallet.R
@Composable

View file

@ -0,0 +1,62 @@
package com.tangem.tap.features.tokens.addCustomToken
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import com.google.accompanist.appcompattheme.AppCompatTheme
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.domainStore
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.tokens.addCustomToken.compose.AddCustomTokenScreen
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
/**
[REDACTED_AUTHOR]
*/
class AddCustomTokenFragment : BaseStoreFragment(R.layout.view_compose_fragment), StoreSubscriber<AddCustomTokenState> {
private var state: MutableState<AddCustomTokenState> = mutableStateOf(domainStore.state.addCustomTokensState)
override fun subscribeToStore() {
domainStore.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.addCustomTokensState == newState.addCustomTokensState
}.select { it.addCustomTokensState }
}
}
override fun newState(state: AddCustomTokenState) {
if (activity == null || view == null) return
this.state.value = state
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
view.findViewById<Toolbar>(R.id.toolbar)?.let {
it.setTitle(R.string.add_custom_token_title)
}
view.findViewById<ComposeView>(R.id.view_compose)?.setContent {
AppCompatTheme(requireContext()) {
Box(modifier = Modifier
.fillMaxSize()
) {
AddCustomTokenScreen(state)
}
}
}
// addBackPressHandler(this)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.tap.features.tokens.addCustomToken
import android.content.Context
import com.tangem.domain.DomainError
import com.tangem.domain.ErrorConverter
import com.tangem.domain.features.addCustomToken.AddCustomTokenError
import com.tangem.domain.features.addCustomToken.AddCustomTokenWarning
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class CustomTokenErrorConverter(
private val context: Context
) : ErrorConverter<String> {
override fun convertError(error: DomainError): String {
val customTokenError = (error as? AddCustomTokenError) ?: throw UnsupportedOperationException()
val resId = when (customTokenError) {
AddCustomTokenError.InvalidContractAddress -> R.string.custom_token_creation_error_invalid_contract_address
AddCustomTokenError.NetworkIsNotSelected -> R.string.custom_token_creation_error_network_not_selected
AddCustomTokenError.InvalidDerivationPath -> R.string.custom_token_creation_error_invalid_derivation_path
AddCustomTokenError.FieldIsEmpty -> R.string.custom_token_creation_error_empty_fields
else -> null
}
return resId?.let { context.getString(it) } ?: "Unknown error: ${customTokenError::class.java.simpleName}"
}
}
class CustomTokenWarningConverter(
private val context: Context
) : ErrorConverter<String> {
override fun convertError(error: DomainError): String {
val customTokenWarning = (error as? AddCustomTokenWarning) ?: throw UnsupportedOperationException()
val resId = when (customTokenWarning) {
AddCustomTokenWarning.PotentialScamToken -> R.string.custom_token_validation_error_not_found
AddCustomTokenWarning.TokenAlreadyAdded -> R.string.custom_token_validation_error_already_added
}
return context.getString(resId)
}
}

View file

@ -0,0 +1,283 @@
package com.tangem.tap.features.tokens.addCustomToken.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.domain.ErrorConverter
import com.tangem.domain.common.form.DataField
import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.*
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.*
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.features.addCustomToken.redux.ScreenState
import com.tangem.domain.features.addCustomToken.redux.ViewStates
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.compose.ComposeDialogManager
import com.tangem.tap.common.compose.OutlinedTextFieldWidget
import com.tangem.tap.common.compose.SpacerH8
import com.tangem.tap.common.compose.keyboardObserverAsState
import com.tangem.tap.features.tokens.addCustomToken.CustomTokenErrorConverter
import com.tangem.tap.features.tokens.addCustomToken.CustomTokenWarningConverter
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
private class AddCustomTokenScreen {} // for simple search
@Composable
fun AddCustomTokenScreen(state: MutableState<AddCustomTokenState>) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
backgroundColor = colorResource(id = R.color.backgroundLightGray),
) {
Box(Modifier.fillMaxSize()) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp)
) {
item {
Surface(
modifier = Modifier.padding(16.dp),
shape = RoundedCornerShape(4.dp),
elevation = 4.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
FormFields(state)
}
}
}
item { Warnings(state.value.warnings.toList()) }
}
HangingOverKeyboardView(
modifier = Modifier
.align(Alignment.BottomCenter),
keyboardState = keyboardObserverAsState(),
defaultBottomPadding = 30.dp,
spaceBetweenKeyboard = 20.dp,
) {
AddButton(
isEnabled = state.value.screenState.addButton.isEnabled
) {
}
}
}
ComposeDialogManager()
}
LaunchedEffect(key1 = Unit, block = { domainStore.dispatch(AddCustomTokenAction.OnCreate) })
DisposableEffect(key1 = Unit, effect = { onDispose { domainStore.dispatch(AddCustomTokenAction.OnDestroy) } })
}
@Composable
private fun FormFields(state: MutableState<AddCustomTokenState>) {
val context = LocalContext.current
val errorConverter = remember { CustomTokenErrorConverter(context) }
state.value.form.fieldList.forEach { field ->
val data = ScreenFieldData.fromState(field, state.value, errorConverter)
when (field.id) {
ContractAddress -> TokenContractAddressView(data)
Network -> TokenNetworkView(data)
Name -> TokenNameView(data)
Symbol -> TokenSymbolView(data)
Decimals -> TokenDecimalsView(data)
DerivationPath -> TokenDerivationPathView(data)
}
}
}
@Composable
private fun TokenContractAddressView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
labelId = R.string.custom_token_contract_address_input_title,
placeholder = "0x0000000000000000",
isEnabled = screenFieldData.viewState.isEnabled,
isLoading = screenFieldData.viewState.isLoading,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) {
domainStore.dispatch(OnTokenContractAddressChanged(Field.Data(it)))
}
SpacerH8()
}
@Composable
private fun TokenNameView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
labelId = R.string.custom_token_name_input_title,
placeholderId = R.string.custom_token_name_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) {
domainStore.dispatch(OnTokenFieldChanged(screenFieldData.field.id, Field.Data(it)))
}
SpacerH8()
}
@Composable
private fun TokenNetworkView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val notSelected = stringResource(id = R.string.custom_token_network_input_not_selected)
val networkField = screenFieldData.field as TokenNetworkField
TokenNetworkSpinner(
title = R.string.custom_token_network_input_title,
itemList = networkField.itemList,
selectedItem = networkField.data,
isEnabled = screenFieldData.viewState.isEnabled,
itemNameConverter = { AddCustomTokenState.convertBlockchainName(it, notSelected) },
) { domainStore.dispatch(OnTokenNetworkChanged(Field.Data(it))) }
SpacerH8()
}
@Composable
private fun TokenSymbolView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
labelId = R.string.custom_token_token_symbol_input_title,
placeholderId = R.string.custom_token_token_symbol_input_placeholder,
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
) { domainStore.dispatch(OnTokenFieldChanged(screenFieldData.field.id, Field.Data(it))) }
SpacerH8()
}
@Composable
private fun TokenDecimalsView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val tokenField = screenFieldData.field as TokenField
OutlinedTextFieldWidget(
textFieldData = tokenField.data,
labelId = R.string.custom_token_decimals_input_title,
placeholder = "8",
isEnabled = screenFieldData.viewState.isEnabled,
error = screenFieldData.error,
errorConverter = screenFieldData.errorConverter,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
) { domainStore.dispatch(OnTokenDecimalsChanged(Field.Data(it))) }
SpacerH8()
}
@Composable
private fun TokenDerivationPathView(screenFieldData: ScreenFieldData) {
if (!screenFieldData.viewState.isVisible) return
val notSelected = stringResource(id = R.string.custom_token_derivation_path_default)
val networkField = screenFieldData.field as TokenDerivationPathField
TokenNetworkSpinner(
title = R.string.custom_token_network_input_title,
itemList = networkField.itemList,
selectedItem = networkField.data,
isEnabled = screenFieldData.viewState.isEnabled,
itemNameConverter = { AddCustomTokenState.convertDerivationPathName(it, notSelected) },
) { domainStore.dispatch(OnTokenDerivationPathChanged(Field.Data(it))) }
SpacerH8()
}
@Composable
private fun Warnings(warnings: List<AddCustomTokenWarning>) {
if (warnings.isEmpty()) return
val context = LocalContext.current
val warningConverter = remember { CustomTokenWarningConverter(context) }
Column {
warnings.forEachIndexed { index, item ->
val modifier = when (index) {
0 -> Modifier.padding(16.dp, 0.dp, 16.dp, 16.dp)
warnings.lastIndex -> Modifier.padding(16.dp, 16.dp, 16.dp, 16.dp)
else -> Modifier.padding(16.dp, 16.dp, 16.dp, 0.dp)
}
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = colorResource(id = R.color.darkGray2),
contentColor = colorResource(id = R.color.darkGray3)
) {
Text(
modifier = Modifier.padding(16.dp),
text = warningConverter.convertError(item),
color = colorResource(id = R.color.lightGray0),
fontSize = 14.sp
)
}
}
}
}
private data class ScreenFieldData(
val field: DataField<*>,
val error: AddCustomTokenError?,
val errorConverter: ErrorConverter<String>,
val viewState: ViewStates.TokenField
) {
companion object {
fun fromState(
field: DataField<*>,
state: AddCustomTokenState,
errorConverter: CustomTokenErrorConverter
): ScreenFieldData {
return ScreenFieldData(
field = field,
error = state.getError(field.id),
errorConverter = errorConverter,
viewState = selectField(field.id, state.screenState)
)
}
private fun selectField(id: FieldId, screenState: ScreenState): ViewStates.TokenField {
return when (id) {
ContractAddress -> screenState.contractAddressField
Network -> screenState.network
Name -> screenState.name
Symbol -> screenState.symbol
Decimals -> screenState.decimals
DerivationPath -> screenState.derivationPath
else -> throw UnsupportedOperationException()
}
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.tap.features.tokens.addCustomToken.compose
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.form.Field
import com.tangem.tap.common.compose.Button
import com.tangem.tap.common.compose.OutlinedSpinner
import com.tangem.tap.common.extensions.ValueCallback
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
fun TokenNetworkSpinner(
title: Int,
itemList: List<Blockchain>,
selectedItem: Field.Data<Blockchain>,
isEnabled: Boolean = true,
itemNameConverter: (Blockchain) -> String,
onItemSelected: ValueCallback<Blockchain>,
) {
OutlinedSpinner(
modifier = Modifier.fillMaxWidth(),
title = stringResource(id = title),
itemList = itemList,
selectedItem = selectedItem,
itemNameConverter = itemNameConverter,
isEnabled = isEnabled,
onItemSelected = onItemSelected
)
}
@Composable
fun AddButton(
modifier: Modifier = Modifier,
isEnabled: Boolean,
textId: Int = R.string.common_add,
onClick: () -> Unit,
) {
Button(
textId = textId,
isEnabled = isEnabled,
modifier = modifier
.height(52.dp)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
leadingView = {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
)
},
onClick = onClick
)
}

View file

@ -0,0 +1,56 @@
package com.tangem.tap.features.tokens.addCustomToken.compose
import android.content.Context
import android.util.TypedValue
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.tangem_sdk_new.extensions.pxToDp
import com.tangem.tap.common.compose.Keyboard
/**
[REDACTED_AUTHOR]
*/
@Composable
fun HangingOverKeyboardView(
modifier: Modifier = Modifier,
keyboardState: State<Keyboard>,
defaultBottomPadding: Dp = 0.dp,
spaceBetweenKeyboard: Dp = 10.dp,
calculateWithActionBarHeight: Boolean = true,
content: @Composable() (BoxScope.() -> Unit)
) {
fun getActionBarHeight(context: Context): Int {
val typedValue = TypedValue()
return if (context.theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
val data = typedValue.data
val displayMetrics = context.resources.displayMetrics
TypedValue.complexToDimensionPixelSize(data, displayMetrics)
} else {
0
}
}
val context = LocalContext.current
val calculatedPadding = when (keyboardState.value) {
Keyboard.Closed -> defaultBottomPadding
is Keyboard.Opened -> {
val keyboardHeight = (keyboardState.value as Keyboard.Opened).height
val keyboardPadding = context.pxToDp(keyboardHeight.toFloat()).dp
if (calculateWithActionBarHeight) {
val actionBarHeight = context.pxToDp(getActionBarHeight(context).toFloat()).dp
keyboardPadding + spaceBetweenKeyboard - actionBarHeight
} else {
keyboardPadding + spaceBetweenKeyboard
}
}
}
Box(modifier.padding(bottom = calculatedPadding)) { content() }
}