From a73558501c5f1e6e7fe3a827c062b365382e3361 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 Apr 2022 16:51:32 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/tap/common/compose/Button.kt | 96 ++++-- .../common/compose/ComposableTextDebouncer.kt | 24 +- .../common/compose/ComposeDialogManager.kt | 125 ++++++++ .../common/compose/ComposeKeyboardObserver.kt | 40 +++ .../tangem/tap/common/compose/ErrorViews.kt | 16 + .../java/com/tangem/tap/common/compose/Log.kt | 12 - .../tap/common/compose/OutlinedSpinner.kt | 18 +- .../common/compose/OutlinedTextFieldWidget.kt | 78 ++--- .../compose => compose/extensions}/Color.kt | 2 +- .../common/compose/extensions/Resources.kt | 21 ++ .../home/compose/StoriesGeneralContent.kt | 2 +- .../addCustomToken/AddCustomTokenFragment.kt | 62 ++++ .../CustomTokenErrorConverter.kt | 44 +++ .../compose/AddCustomTokenScreen.kt | 283 ++++++++++++++++++ .../compose/AddCustomTokenViews.kt | 66 ++++ .../compose/HangingOverKeyboardView.kt | 56 ++++ 16 files changed, 849 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt create mode 100644 app/src/main/java/com/tangem/tap/common/compose/ComposeKeyboardObserver.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/compose/Log.kt rename app/src/main/java/com/tangem/tap/common/{extensions/compose => compose/extensions}/Color.kt (91%) create mode 100644 app/src/main/java/com/tangem/tap/common/compose/extensions/Resources.kt create mode 100644 app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/AddCustomTokenFragment.kt create mode 100644 app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/CustomTokenErrorConverter.kt create mode 100644 app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt create mode 100644 app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenViews.kt create mode 100644 app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/HangingOverKeyboardView.kt diff --git a/app/src/main/java/com/tangem/tap/common/compose/Button.kt b/app/src/main/java/com/tangem/tap/common/compose/Button.kt index fd450f68bd..64b9daa152 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/Button.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/Button.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/ComposableTextDebouncer.kt b/app/src/main/java/com/tangem/tap/common/compose/ComposableTextDebouncer.kt index 7420dce292..0e9040f220 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/ComposableTextDebouncer.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/ComposableTextDebouncer.kt @@ -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 valueDebouncerAsState( debounce: Long = 400, - onTextChanged: (String) -> Unit -): ValueDebouncer { - val rTextDebounce = remember { - mutableStateOf(ValueDebouncer(text, debounce) { changedValue -> - changedValue?.let { onTextChanged(it) } - }) + onValueChanged: (T) -> Unit +): ValueDebouncer { + return remember { + ValueDebouncer( + debounce = debounce, + onValueChanged = { changedValue -> + changedValue?.let { onValueChanged(it) } + }, + ) } - rTextDebounce.value.value = text - - return rTextDebounce.value } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt b/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt new file mode 100644 index 0000000000..d1f540e7e1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt @@ -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(null) } + val subscriber = remember { + object : StoreSubscriber { + 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) { + 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 SimpleDialog( + title: String, + items: List, + 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), + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/ComposeKeyboardObserver.kt b/app/src/main/java/com/tangem/tap/common/compose/ComposeKeyboardObserver.kt new file mode 100644 index 0000000000..fe045a7c9e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/compose/ComposeKeyboardObserver.kt @@ -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 { + val keyboardState: MutableState = 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() +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/ErrorViews.kt b/app/src/main/java/com/tangem/tap/common/compose/ErrorViews.kt index 7055706474..49b6625474 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/ErrorViews.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/ErrorViews.kt @@ -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") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/Log.kt b/app/src/main/java/com/tangem/tap/common/compose/Log.kt deleted file mode 100644 index 851798dfe1..0000000000 --- a/app/src/main/java/com/tangem/tap/common/compose/Log.kt +++ /dev/null @@ -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") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/compose/OutlinedSpinner.kt b/app/src/main/java/com/tangem/tap/common/compose/OutlinedSpinner.kt index 7be01ec1d0..ef87477734 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/OutlinedSpinner.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/OutlinedSpinner.kt @@ -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 OutlinedSpinner( + modifier: Modifier = Modifier, title: String, itemList: List, - selectedItem: T, + selectedItem: Field.Data, onItemSelected: ValueCallback, - 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 OutlinedSpinner( OutlinedTextField( modifier = modifier, readOnly = true, + enabled = isEnabled, value = itemNameConverter(rSelectedItem.value), onValueChange = {}, label = { Text(title) }, @@ -66,12 +74,12 @@ fun 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 = {}, ) } diff --git a/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt b/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt index 65429321eb..b49579609d 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt @@ -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, labelId: Int? = null, label: String = "", placeholderId: Int? = null, @@ -44,62 +46,66 @@ fun OutlinedTextFieldWidget( errorConverter: ErrorConverter? = 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, 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, ) { @@ -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 = {}, - ) + ) {} } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/compose/Color.kt b/app/src/main/java/com/tangem/tap/common/compose/extensions/Color.kt similarity index 91% rename from app/src/main/java/com/tangem/tap/common/extensions/compose/Color.kt rename to app/src/main/java/com/tangem/tap/common/compose/extensions/Color.kt index fb4d02453c..d25bc9e26b 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/compose/Color.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/extensions/Color.kt @@ -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 diff --git a/app/src/main/java/com/tangem/tap/common/compose/extensions/Resources.kt b/app/src/main/java/com/tangem/tap/common/compose/extensions/Resources.kt new file mode 100644 index 0000000000..5b8da92e24 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/compose/extensions/Resources.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesGeneralContent.kt b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesGeneralContent.kt index 05c11fbd75..410a8118aa 100644 --- a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesGeneralContent.kt +++ b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesGeneralContent.kt @@ -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 diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/AddCustomTokenFragment.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/AddCustomTokenFragment.kt new file mode 100644 index 0000000000..0c6e8bc2a1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/AddCustomTokenFragment.kt @@ -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 { + + private var state: MutableState = 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(R.id.toolbar)?.let { + it.setTitle(R.string.add_custom_token_title) + } + + view.findViewById(R.id.view_compose)?.setContent { + AppCompatTheme(requireContext()) { + Box(modifier = Modifier + .fillMaxSize() + ) { + AddCustomTokenScreen(state) + } + + } + } +// addBackPressHandler(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/CustomTokenErrorConverter.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/CustomTokenErrorConverter.kt new file mode 100644 index 0000000000..17373aa948 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/CustomTokenErrorConverter.kt @@ -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 { + + 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 { + + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt new file mode 100644 index 0000000000..f1cc9dcfbf --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt @@ -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) { + 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) { + 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) { + 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, + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenViews.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenViews.kt new file mode 100644 index 0000000000..dee8d3b535 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenViews.kt @@ -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, + selectedItem: Field.Data, + isEnabled: Boolean = true, + itemNameConverter: (Blockchain) -> String, + onItemSelected: ValueCallback, +) { + + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/HangingOverKeyboardView.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/HangingOverKeyboardView.kt new file mode 100644 index 0000000000..0a8f736373 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/HangingOverKeyboardView.kt @@ -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, + 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() } +} \ No newline at end of file