Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-25 12:53:58 +03:00
commit cad324a23b
47 changed files with 4143 additions and 20 deletions

View file

@ -24,6 +24,7 @@ dependencies {
implementation(project(":libs:auth"))
/** Features */
implementation(project(":features:onboarding"))
implementation(project(":features:referral:presentation"))
implementation(project(":features:referral:domain"))
implementation(project(":features:referral:data"))

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,22C8.735,22 8.48,21.895 8.293,21.707C8.105,21.52 8,21.265 8,21V18H4C3.47,18 2.961,17.789 2.586,17.414C2.211,17.039 2,16.53 2,16V4C2,2.89 2.9,2 4,2H20C20.53,2 21.039,2.211 21.414,2.586C21.789,2.961 22,3.47 22,4V16C22,16.53 21.789,17.039 21.414,17.414C21.039,17.789 20.53,18 20,18H13.9L10.2,21.71C10,21.9 9.75,22 9.5,22H9ZM10,16V19.08L13.08,16H20V4H4V16H10ZM6,7H18V9H6V7ZM6,11H15V13H6V11Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M4,4H10V10H4V4ZM20,4V10H14V4H20ZM14,15H16V13H14V11H16V13H18V11H20V13H18V15H20V18H18V20H16V18H13V20H11V16H14V15ZM16,15V18H18V15H16ZM4,20V14H10V20H4ZM6,6V8H8V6H6ZM16,6V8H18V6H16ZM6,16V18H8V16H6ZM4,11H6V13H4V11ZM9,11H13V15H11V13H9V11ZM11,6H13V10H11V6ZM2,2V6H0V2C0,1.47 0.211,0.961 0.586,0.586C0.961,0.211 1.47,0 2,0L6,0V2H2ZM22,0C22.53,0 23.039,0.211 23.414,0.586C23.789,0.961 24,1.47 24,2V6H22V2H18V0H22ZM2,18V22H6V24H2C1.47,24 0.961,23.789 0.586,23.414C0.211,23.039 0,22.53 0,22V18H2ZM22,22V18H24V22C24,22.53 23.789,23.039 23.414,23.414C23.039,23.789 22.53,24 22,24H18V22H22Z"
android:fillColor="#000000"/>
</group>
</vector>

View file

@ -10,4 +10,5 @@ object ModuleErrorCode {
const val NETWORK = 20000
const val DOMAIN = 30000
const val SALT_PAY = 40000
const val ONBOARDING = 50000
}

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.asset
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.BufferedReader
import java.io.InputStream
import javax.inject.Inject
/**
@ -15,9 +16,13 @@ internal class AndroidAssetReader @Inject constructor(
) : AssetReader {
override fun readJson(fileName: String): String {
return context.assets
.open("$fileName.json")
return openFile("$fileName.json")
.bufferedReader()
.use(BufferedReader::readText)
}
override fun openFile(fileName: String): InputStream {
return context.assets
.open(fileName)
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.datasource.asset
import java.io.InputStream
/**
* Asset file reader
*
@ -9,4 +11,7 @@ interface AssetReader {
/** Read content of json file [fileName] from asset */
fun readJson(fileName: String): String
/** Open a file [file] from asset as InputStream */
fun openFile(file: String): InputStream
}

View file

@ -141,19 +141,27 @@ private fun TangemTextField(
},
trailingIcon = {
val iconRes by rememberUpdatedState(
newValue = if (isError) R.drawable.ic_alert_24 else R.drawable.ic_close_24,
newValue = if (isError) {
R.drawable.ic_alert_24
} else if (value.text.isNotEmpty()) {
R.drawable.ic_close_24
} else {
null
},
)
IconButton(
modifier = Modifier.size(32.dp),
onClick = onClear,
enabled = !isError && enabled,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = iconRes),
tint = colors.trailingIconColor(enabled = enabled, isError = isError).value,
contentDescription = "Clear input",
)
if (iconRes != null) {
IconButton(
modifier = Modifier.size(32.dp),
onClick = onClear,
enabled = !isError && enabled,
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = iconRes!!),
tint = colors.trailingIconColor(enabled = enabled, isError = isError).value,
contentDescription = "Clear input",
)
}
}
},
)
@ -193,7 +201,7 @@ private fun TangemTextFieldSize.toShape(): Shape = when (this) {
TangemTextFieldSize.Default -> TangemTheme.shapes.roundedCornersSmall2
}
internal object TangemTextFieldsDefault {
object TangemTextFieldsDefault {
val defaultTextFieldColors: TangemTextFieldColors
@Composable @Stable get() = TangemTextFieldColors(
textColor = TangemTheme.colors.text.primary1,
@ -224,7 +232,7 @@ internal object TangemTextFieldsDefault {
}
@Immutable
internal data class TangemTextFieldColors(
data class TangemTextFieldColors(
private val textColor: Color,
private val disabledTextColor: Color,
private val cursorColor: Color,

View file

@ -47,6 +47,7 @@ data class TangemDimens internal constructor(
val size24: Dp = 24.dp,
val size28: Dp = 28.dp,
val size32: Dp = 32.dp,
val size34: Dp = 34.dp,
val size36: Dp = 36.dp,
val size40: Dp = 40.dp,
val size42: Dp = 42.dp,

View file

@ -0,0 +1,88 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="214dp"
android:height="130dp"
android:viewportWidth="214"
android:viewportHeight="130">
<path
android:fillColor="#1B1D1C"
android:pathData="M1.043,8.273C1.019,4.256 4.265,1 8.294,1H205.706C209.735,1 213.019,4.256 213.043,8.273L213.709,121.725C213.733,125.742 210.487,128.998 206.459,128.998H9.046C5.018,128.998 1.733,125.742 1.709,121.725L1.043,8.273Z" />
<path
android:fillColor="#00000000"
android:pathData="M1.043,8.273C1.019,4.256 4.265,1 8.294,1H205.706C209.735,1 213.019,4.256 213.043,8.273L213.709,121.725C213.733,125.742 210.487,128.998 206.459,128.998H9.046C5.018,128.998 1.733,125.742 1.709,121.725L1.043,8.273Z"
android:strokeWidth="0.5" />
<path
android:fillColor="#A5A5A5"
android:pathData="M25.274,26.4L25.274,32.64L28.079,32.64C28.188,32.639 28.292,32.596 28.369,32.522C28.446,32.447 28.49,32.346 28.491,32.241L28.491,26.4L25.274,26.4Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M19,32.241C19.001,32.346 19.045,32.447 19.122,32.522C19.199,32.596 19.303,32.639 19.412,32.64L22.217,32.64L22.217,26.4L19,26.4L19,32.241Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M28.491,23.2L28.491,20.409C28.491,20.301 28.448,20.197 28.372,20.12C28.296,20.043 28.194,20 28.086,20L19.405,20C19.297,20 19.195,20.043 19.119,20.12C19.043,20.197 19,20.301 19,20.409L19,23.2L28.491,23.2Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M43.503,24.163C42.922,23.921 42.292,23.812 41.661,23.846C40.827,23.844 39.996,23.934 39.183,24.113L39.183,25.532C39.832,25.455 40.483,25.412 41.136,25.4C41.486,25.385 41.836,25.418 42.177,25.5C42.286,25.532 42.385,25.59 42.465,25.669C42.546,25.748 42.605,25.846 42.637,25.953C42.74,26.334 42.783,26.728 42.766,27.122L42.766,27.539C41.997,27.498 41.385,27.476 40.965,27.476C40.519,27.454 40.073,27.539 39.667,27.725C39.347,27.903 39.109,28.195 38.999,28.54C38.847,29.044 38.779,29.569 38.796,30.094C38.74,30.771 38.901,31.448 39.257,32.029C39.445,32.243 39.683,32.409 39.95,32.513C40.216,32.618 40.505,32.659 40.791,32.632C41.127,32.632 42.172,32.605 42.633,32.007C42.709,31.912 42.774,31.808 42.826,31.698L42.877,32.496L44.572,32.496L44.572,27.222C44.596,26.539 44.52,25.857 44.346,25.196C44.284,24.977 44.178,24.772 44.033,24.594C43.888,24.417 43.708,24.27 43.503,24.163ZM42.748,29.256C42.796,29.736 42.713,30.219 42.508,30.656C42.409,30.782 42.272,30.876 42.117,30.924C41.942,30.972 41.76,30.995 41.578,30.992C41.294,31.028 41.006,30.953 40.777,30.783C40.623,30.523 40.558,30.221 40.592,29.922C40.582,29.681 40.607,29.44 40.666,29.206C40.684,29.139 40.716,29.075 40.761,29.021C40.805,28.966 40.861,28.921 40.924,28.889C41.098,28.821 41.285,28.791 41.472,28.803L42.748,28.803L42.748,29.256Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M58.169,24.645C58.133,24.584 58.095,24.526 58.053,24.471C57.494,23.736 56.162,23.846 56.162,23.846C55.722,23.831 55.29,23.964 54.929,24.222C54.564,24.573 54.329,25.042 54.263,25.55C54.001,27.328 54.001,29.136 54.263,30.914C54.326,31.417 54.555,31.882 54.911,32.233C55.299,32.52 55.774,32.659 56.251,32.623C56.251,32.623 56.278,32.623 56.296,32.623C56.296,32.623 57.498,32.72 58.084,32.104C58.095,32.607 58.062,33.11 57.985,33.606C57.966,33.738 57.915,33.864 57.837,33.971C57.759,34.078 57.656,34.164 57.539,34.222C57.166,34.351 56.773,34.406 56.381,34.382L54.535,34.35L54.535,35.788C54.876,35.853 55.219,35.9 55.563,35.93C55.988,35.972 56.39,35.99 56.774,35.99C57.49,36.044 58.205,35.884 58.834,35.53C59.055,35.374 59.242,35.174 59.386,34.943C59.529,34.71 59.626,34.451 59.67,34.18C59.813,33.297 59.875,32.402 59.853,31.507L59.853,23.979L58.209,23.979L58.169,24.645ZM57.963,30.221C57.936,30.452 57.824,30.664 57.65,30.813C57.447,30.94 57.212,30.999 56.975,30.983C56.711,31.006 56.446,30.95 56.211,30.823C56.028,30.636 55.923,30.385 55.916,30.12C55.815,28.876 55.815,27.625 55.916,26.381C55.923,26.125 56.025,25.882 56.202,25.701C56.442,25.579 56.709,25.526 56.975,25.55C57.227,25.529 57.479,25.599 57.686,25.747C57.87,25.947 57.981,26.207 57.999,26.482C58.066,27.072 58.093,27.666 58.079,28.26C58.101,28.916 58.062,29.573 57.963,30.221Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M66.713,24.124C66.131,23.787 65.461,23.636 64.793,23.691C64.098,23.642 63.404,23.802 62.799,24.152C62.358,24.473 62.062,24.959 61.975,25.502C61.831,26.388 61.77,27.286 61.793,28.184C61.769,29.083 61.833,29.983 61.984,30.87C62.031,31.139 62.131,31.395 62.278,31.623C62.425,31.851 62.616,32.046 62.84,32.197C63.478,32.531 64.194,32.681 64.911,32.631C65.367,32.63 65.823,32.603 66.276,32.552C66.653,32.517 67.025,32.453 67.392,32.359L67.392,30.916C66.554,31.004 65.867,31.05 65.33,31.05C64.933,31.074 64.535,31.025 64.155,30.907C64.038,30.857 63.936,30.778 63.856,30.677C63.777,30.577 63.723,30.458 63.7,30.331C63.618,29.823 63.585,29.307 63.6,28.792L67.574,28.792L67.574,28.165C67.596,27.241 67.548,26.317 67.433,25.4C67.368,24.896 67.109,24.438 66.713,24.124ZM65.849,27.428L63.6,27.428C63.6,26.95 63.632,26.473 63.696,26C63.704,25.887 63.736,25.777 63.79,25.678C63.843,25.579 63.917,25.492 64.005,25.424C64.473,25.233 64.994,25.233 65.462,25.424C65.548,25.494 65.619,25.583 65.671,25.683C65.723,25.782 65.754,25.892 65.762,26.004C65.833,26.475 65.862,26.952 65.849,27.428Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M78.824,25.12C78.747,24.744 78.542,24.406 78.243,24.161C77.886,23.928 77.46,23.817 77.032,23.844C77.032,23.844 76.991,23.844 76.963,23.844C76.963,23.844 75.581,23.767 75.01,24.464C74.948,24.542 74.892,24.625 74.844,24.712C74.752,24.478 74.592,24.275 74.383,24.129C74.024,23.923 73.61,23.826 73.195,23.848C73.195,23.848 71.813,23.767 71.242,24.468C71.178,24.545 71.122,24.629 71.076,24.717L71.03,23.984L69.349,23.984L69.349,32.48L71.191,32.48L71.191,28.051C71.182,27.52 71.201,26.988 71.251,26.459C71.263,26.198 71.362,25.949 71.532,25.749C71.736,25.584 71.997,25.506 72.26,25.531C72.493,25.513 72.727,25.557 72.937,25.658C73.015,25.731 73.076,25.819 73.119,25.916C73.162,26.013 73.185,26.118 73.186,26.223C73.234,26.747 73.254,27.272 73.245,27.798L73.245,32.48L75.088,32.48L75.088,28.051C75.088,27.314 75.088,26.785 75.143,26.459C75.156,26.197 75.258,25.946 75.434,25.749C75.642,25.586 75.906,25.508 76.171,25.531C76.405,25.513 76.641,25.557 76.852,25.658C76.929,25.732 76.99,25.82 77.032,25.917C77.074,26.014 77.096,26.118 77.096,26.223C77.147,26.747 77.167,27.272 77.156,27.798L77.156,32.48L78.999,32.48L78.999,26.884C79.009,26.291 78.951,25.699 78.824,25.12Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M51.691,24.171C51.307,23.928 50.851,23.814 50.394,23.845C50.394,23.845 50.356,23.845 50.338,23.845C50.338,23.845 48.938,23.773 48.364,24.465C48.323,24.514 48.286,24.565 48.252,24.619L48.21,23.981L46.507,23.981L46.507,32.48L48.373,32.48L48.373,28.049C48.363,27.521 48.384,26.992 48.434,26.465C48.448,26.203 48.553,25.952 48.733,25.755C48.958,25.584 49.242,25.503 49.526,25.528C49.774,25.511 50.023,25.555 50.249,25.655C50.333,25.728 50.401,25.816 50.448,25.915C50.496,26.014 50.521,26.121 50.524,26.23C50.578,26.75 50.6,27.273 50.59,27.796L50.59,32.48L52.456,32.48L52.456,26.963C52.471,26.356 52.419,25.749 52.302,25.153C52.226,24.765 52.009,24.417 51.691,24.171Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M36.543,21.44L34.653,21.44L34.653,23.877L33.477,23.877L33.477,25.434L34.653,25.434L34.653,32.48L36.543,32.48L36.543,25.434L37.981,25.434L37.981,23.877L36.543,23.877L36.543,21.44Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M23.463,105.118L21.826,104.112L21.904,106H21.024L21.087,104.112L19.441,105.125L19.02,104.335L20.707,103.493L19.016,102.645L19.428,101.882L21.08,102.875L21.001,101H21.874L21.818,102.882L23.451,101.882L23.885,102.645L22.184,103.5L23.895,104.342L23.463,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M32.161,105.118L30.524,104.112L30.602,106H29.722L29.785,104.112L28.139,105.125L27.718,104.335L29.405,103.493L27.715,102.645L28.126,101.882L29.778,102.875L29.699,101H30.573L30.517,102.882L32.149,101.882L32.583,102.645L30.883,103.5L32.593,104.342L32.161,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M40.86,105.118L39.222,104.112L39.3,106H38.42L38.483,104.112L36.837,105.125L36.416,104.335L38.104,103.493L36.413,102.645L36.825,101.882L38.476,102.875L38.398,101H39.271L39.215,102.882L40.847,101.882L41.282,102.645L39.581,103.5L41.292,104.342L40.86,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M49.558,105.118L47.92,104.112L47.998,106H47.119L47.181,104.112L45.535,105.125L45.114,104.335L46.802,103.493L45.111,102.645L45.523,101.882L47.174,102.875L47.096,101H47.969L47.913,102.882L49.546,101.882L49.98,102.645L48.279,103.5L49.99,104.342L49.558,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M63.14,105.118L61.502,104.112L61.58,106H60.701L60.763,104.112L59.117,105.125L58.696,104.335L60.384,103.493L58.693,102.645L59.105,101.882L60.756,102.875L60.678,101H61.551L61.495,102.882L63.128,101.882L63.562,102.645L61.861,103.5L63.572,104.342L63.14,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M71.838,105.118L70.2,104.112L70.279,106H69.399L69.462,104.112L67.815,105.125L67.394,104.335L69.082,103.493L67.391,102.645L67.803,101.882L69.454,102.875L69.376,101H70.249L70.193,102.882L71.826,101.882L72.26,102.645L70.559,103.5L72.27,104.342L71.838,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M80.536,105.118L78.898,104.112L78.977,106H78.097L78.16,104.112L76.514,105.125L76.093,104.335L77.78,103.493L76.089,102.645L76.501,101.882L78.152,102.875L78.074,101H78.947L78.891,102.882L80.524,101.882L80.958,102.645L79.257,103.5L80.968,104.342L80.536,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M89.234,105.118L87.597,104.112L87.675,106H86.795L86.858,104.112L85.212,105.125L84.791,104.335L86.478,103.493L84.788,102.645L85.2,101.882L86.851,102.875L86.772,101H87.646L87.59,102.882L89.222,101.882L89.657,102.645L87.956,103.5L89.666,104.342L89.234,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M102.816,105.118L101.179,104.112L101.257,106H100.377L100.44,104.112L98.794,105.125L98.373,104.335L100.06,103.493L98.369,102.645L98.781,101.882L100.433,102.875L100.354,101H101.227L101.171,102.882L102.804,101.882L103.238,102.645L101.538,103.5L103.248,104.342L102.816,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M111.515,105.118L109.877,104.112L109.955,106H109.075L109.138,104.112L107.492,105.125L107.071,104.335L108.758,103.493L107.068,102.645L107.48,101.882L109.131,102.875L109.053,101H109.926L109.87,102.882L111.502,101.882L111.937,102.645L110.236,103.5L111.947,104.342L111.515,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M120.213,105.118L118.575,104.112L118.653,106H117.774L117.836,104.112L116.19,105.125L115.769,104.335L117.457,103.493L115.766,102.645L116.178,101.882L117.829,102.875L117.751,101H118.624L118.568,102.882L120.201,101.882L120.635,102.645L118.934,103.5L120.645,104.342L120.213,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M128.911,105.118L127.273,104.112L127.352,106H126.472L126.535,104.112L124.888,105.125L124.467,104.335L126.155,103.493L124.464,102.645L124.876,101.882L126.527,102.875L126.449,101H127.322L127.266,102.882L128.899,101.882L129.333,102.645L127.632,103.5L129.343,104.342L128.911,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M142.493,105.118L140.855,104.112L140.933,106H140.054L140.116,104.112L138.47,105.125L138.049,104.335L139.737,103.493L138.046,102.645L138.458,101.882L140.109,102.875L140.031,101H140.904L140.848,102.882L142.481,101.882L142.915,102.645L141.214,103.5L142.925,104.342L142.493,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M151.191,105.118L149.553,104.112L149.632,106H148.752L148.815,104.112L147.169,105.125L146.748,104.335L148.435,103.493L146.744,102.645L147.156,101.882L148.807,102.875L148.729,101H149.602L149.546,102.882L151.179,101.882L151.613,102.645L149.912,103.5L151.623,104.342L151.191,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M159.889,105.118L158.252,104.112L158.33,106H157.45L157.513,104.112L155.867,105.125L155.446,104.335L157.133,103.493L155.443,102.645L155.854,101.882L157.506,102.875L157.427,101H158.3L158.244,102.882L159.877,101.882L160.311,102.645L158.611,103.5L160.321,104.342L159.889,105.118Z" />
<path
android:fillColor="#A5A5A5"
android:pathData="M168.588,105.118L166.95,104.112L167.028,106H166.148L166.211,104.112L164.565,105.125L164.144,104.335L165.831,103.493L164.141,102.645L164.553,101.882L166.204,102.875L166.126,101H166.999L166.943,102.882L168.575,101.882L169.01,102.645L167.309,103.5L169.02,104.342L168.588,105.118Z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M23.333,49H11.667C9.077,49 7,46.923 7,44.333V11.667C7,9.077 9.077,7 11.667,7H44.333C46.923,7 49,9.077 49,11.667V24.103C48.3,23.823 47.53,23.66 46.76,23.66C45.897,23.66 45.08,23.847 44.333,24.197V11.667H11.667V44.333H23.59L23.333,44.59V49ZM16.333,21H39.667V16.333H16.333V21ZM16.333,39.667H28.257L32.667,35.28V35H16.333V39.667ZM16.333,30.333H37.613L39.667,28.28V25.667H16.333V30.333ZM50.633,31.687L47.647,28.7C47.157,28.21 46.34,28.21 45.85,28.7L43.517,31.033L48.3,35.817L50.633,33.483C51.123,32.993 51.123,32.177 50.633,31.687ZM28,51.333H32.807L46.923,37.17L42.14,32.387L28,46.527V51.333Z"
android:fillColor="#919191"/>
</vector>

View file

@ -2,19 +2,25 @@ package com.tangem.utils.coroutines
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors
import javax.inject.Inject
interface CoroutineDispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val single: CoroutineDispatcher
}
class AppCoroutineDispatcherProvider @Inject constructor() : CoroutineDispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
override val single: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
}
class TestingCoroutineDispatcherProvider(
override val main: CoroutineDispatcher = Dispatchers.Unconfined,
override val io: CoroutineDispatcher = Dispatchers.Unconfined,
) : CoroutineDispatcherProvider
override val single: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher(),
) : CoroutineDispatcherProvider

View file

@ -8,6 +8,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
suspend fun <R> runCatching(dispatcher: CoroutineDispatcher, block: suspend () -> R): Result<R> {
return runCatching {
@ -25,11 +27,20 @@ class Debouncer {
private var debounceJob: Job? = null
fun debounce(waitMs: Long = 300L, coroutineScope: CoroutineScope, destinationFunction: () -> Unit) {
fun debounce(
coroutineScope: CoroutineScope,
waitMs: Long = 300L,
context: CoroutineContext = EmptyCoroutineContext,
destinationFunction: suspend () -> Unit,
) {
debounceJob?.cancel()
debounceJob = coroutineScope.launch {
debounceJob = coroutineScope.launch(context) {
delay(waitMs)
destinationFunction.invoke()
}
}
fun release() {
debounceJob?.cancel()
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.utils.extensions
/**
[REDACTED_AUTHOR]
*/
fun Char.isNotWhitespace(): Boolean = !this.isWhitespace()

View file

@ -0,0 +1,6 @@
package com.tangem.utils.extensions
/**
[REDACTED_AUTHOR]
*/
fun <T> Collection<T>.isSingleItem(): Boolean = this.size == 1

View file

@ -0,0 +1,8 @@
package com.tangem.utils.extensions
/**
[REDACTED_AUTHOR]
*/
fun Int.isEven(): Boolean = this % 2 == 0
fun Int.isOdd(): Boolean = !this.isEven()

1
features/onboarding/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,50 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
dependencies {
/** Core modules */
implementation(project(":common"))
implementation(project(":core:featuretoggles"))
implementation(project(":core:datasource"))
implementation(project(":core:analytics"))
implementation(project(":core:utils"))
implementation(project(":core:ui"))
implementation(project(":core:res"))
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.constraintLayout)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.runtime.ktx)
implementation(deps.lifecycle.common.java8)
implementation(deps.lifecycle.viewModel.ktx)
/** Compose libraries */
implementation(deps.compose.material)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.compose.shimmer)
implementation(deps.tangem.card.core)
/** Other libraries */
implementation(deps.compose.shimmer)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.onboarding" />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
package com.tangem.feature.onboarding.data
import com.tangem.crypto.bip39.*
import com.tangem.datasource.asset.AssetReader
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMnemonicRepository(
private val assetReader: AssetReader,
) : MnemonicRepository {
// TODO: change initialization after adding it to the sdk
private val bip39Wordlist: Wordlist by lazy {
assetReader.openFile(MNEMONIC_FILE_NAME)
.use { BIP39Wordlist(it) }
}
private val bip39Words: Set<String> by lazy { bip39Wordlist.words.toHashSet() }
override fun getWordsDictionary(): Set<String> = bip39Words
override fun generateDefaultMnemonic(): Mnemonic {
return DefaultMnemonic(EntropyLength.Bits128Length, bip39Wordlist)
}
override fun createMnemonic(mnemonicString: String): Mnemonic = DefaultMnemonic(mnemonicString, bip39Wordlist)
companion object {
private const val MNEMONIC_FILE_NAME = "mnemonic/mnemonic_dictionary_en.txt"
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.onboarding.data
import com.tangem.common.core.TangemSdkError
import com.tangem.crypto.bip39.Mnemonic
/**
[REDACTED_AUTHOR]
*/
interface MnemonicRepository {
fun getWordsDictionary(): Set<String>
@Throws(TangemSdkError.MnemonicException::class)
fun generateDefaultMnemonic(): Mnemonic
@Throws(TangemSdkError.MnemonicException::class)
fun createMnemonic(mnemonicString: String): Mnemonic
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.onboarding.data.di
import com.tangem.datasource.asset.AssetReader
import com.tangem.feature.onboarding.data.DefaultMnemonicRepository
import com.tangem.feature.onboarding.data.MnemonicRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class OnboardingDataModule {
@Provides
@Singleton
fun provideSeedPhraseSdkRepository(assetReader: AssetReader): MnemonicRepository {
return DefaultMnemonicRepository(assetReader)
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.feature.onboarding.domain
import com.tangem.common.core.TangemSdkError
import com.tangem.crypto.bip39.Mnemonic
import com.tangem.crypto.bip39.MnemonicErrorResult
import com.tangem.feature.onboarding.data.MnemonicRepository
import com.tangem.utils.extensions.isNotWhitespace
/**
[REDACTED_AUTHOR]
*/
internal class DefaultSeedPhraseInteractor constructor(
private val repository: MnemonicRepository,
) : SeedPhraseInteractor {
private var currentMnemonic: Mnemonic? = null
private val partWordFinder: PartWordFinder = PartWordFinder()
override suspend fun generateMnemonic(): Result<Mnemonic> {
return runCatching {
repository.generateDefaultMnemonic().apply {
currentMnemonic = this
}
}
}
override suspend fun getMnemonicComponents(): Result<List<String>> {
return runCatching {
val mnemonic = currentMnemonic ?: repository.generateDefaultMnemonic().apply {
currentMnemonic = this
}
mnemonic.mnemonicComponents
}
}
override suspend fun isWordMatch(word: String): Boolean {
return repository.getWordsDictionary().contains(word)
}
override suspend fun validateMnemonicString(text: String): Result<List<String>> {
val inputWords = text.split("\\s+".toRegex()).toSet()
val wordsDictionary = repository.getWordsDictionary()
val invalidWords = inputWords
.filter { it.isNotEmpty() && !wordsDictionary.contains(it) }
.toSet()
return if (invalidWords.isNotEmpty()) {
Result.failure(SeedPhraseError.InvalidWords(invalidWords))
} else {
try {
val mnemonic = repository.createMnemonic(text)
Result.success(mnemonic.mnemonicComponents)
} catch (ex: TangemSdkError.MnemonicException) {
val error = ex.mnemonicResult.mapToError()
Result.failure(error)
}
}
}
override suspend fun getSuggestions(text: String, hasSelection: Boolean, cursorPosition: Int): List<String> {
if (text.isEmpty() || cursorPosition == 0 || hasSelection) return emptyList()
val word = partWordFinder.getLeadPartOfWord(text, cursorPosition)
?: return emptyList()
val suggestions = repository.getWordsDictionary()
.filter { it.startsWith(word, ignoreCase = false) && it != word }
.toList()
return suggestions
}
override suspend fun insertSuggestionWord(
text: String,
suggestion: String,
cursorPosition: Int,
): InsertSuggestionResult {
val textPartLead = text.substring(0, cursorPosition)
val textPartLast = text.substring(cursorPosition, text.length)
val wordPartLast = partWordFinder.getLastPartOfWord(text, suggestion, cursorPosition)
val textBuilder = StringBuilder()
.append(textPartLead)
.append(wordPartLast)
if (textPartLast.isEmpty() || textPartLast[0].isNotWhitespace()) {
textBuilder.append(SeedPhraseInteractor.MNEMONIC_DELIMITER)
}
textBuilder.append(textPartLast)
val textWithSuggestion = textBuilder.toString()
val newCursorPosition = cursorPosition + wordPartLast.length + SeedPhraseInteractor.MNEMONIC_DELIMITER.length
return InsertSuggestionResult(textWithSuggestion, newCursorPosition)
}
}
private fun MnemonicErrorResult.mapToError(): SeedPhraseError = when (this) {
MnemonicErrorResult.InvalidWordCount -> SeedPhraseError.InvalidEntropyLength
MnemonicErrorResult.InvalidEntropyLength -> SeedPhraseError.InvalidEntropyLength
MnemonicErrorResult.InvalidWordsFile -> SeedPhraseError.InvalidWordsFile
MnemonicErrorResult.InvalidChecksum -> SeedPhraseError.InvalidChecksum
MnemonicErrorResult.MnenmonicCreationFailed -> SeedPhraseError.MnenmonicCreationFailed
MnemonicErrorResult.NormalizationFailed -> SeedPhraseError.NormalizationFailed
MnemonicErrorResult.UnsupportedLanguage -> SeedPhraseError.UnsupportedLanguage
is MnemonicErrorResult.InvalidWords -> SeedPhraseError.InvalidWords(this.words)
}

View file

@ -0,0 +1,40 @@
package com.tangem.feature.onboarding.domain
import com.tangem.common.module.ModuleError
import com.tangem.common.module.ModuleErrorCode
import com.tangem.common.module.ModuleMessage
/**
[REDACTED_AUTHOR]
* For each pool of errors, use the codes lying in range 100 (200..299, 300..399, etc)
*/
abstract class OnboardingModuleError(
subCode: Int,
override val message: String,
override val data: Any?,
) : ModuleMessage, ModuleError() {
override val code: Int = ModuleErrorCode.ONBOARDING + subCode
companion object {
internal const val ERROR_CODE_SEED_PHRASE = 100
}
}
sealed class SeedPhraseError(
subCode: Int = 0,
message: String? = null,
data: Any? = null,
) : OnboardingModuleError(
subCode = ERROR_CODE_SEED_PHRASE + subCode,
message = message ?: this::class.java.simpleName,
data = data,
) {
object InvalidWordCount : SeedPhraseError(subCode = 0)
object InvalidEntropyLength : SeedPhraseError(subCode = 1)
object InvalidWordsFile : SeedPhraseError(subCode = 2)
object InvalidChecksum : SeedPhraseError(subCode = 3)
object MnenmonicCreationFailed : SeedPhraseError(subCode = 4)
object NormalizationFailed : SeedPhraseError(subCode = 5)
object UnsupportedLanguage : SeedPhraseError(subCode = 6)
data class InvalidWords(val words: Set<String>) : SeedPhraseError(subCode = 7)
}

View file

@ -0,0 +1,50 @@
package com.tangem.feature.onboarding.domain
import com.tangem.utils.extensions.isNotWhitespace
/**
[REDACTED_AUTHOR]
* Class which provides helper functions to determine lead or last part of words for suggestions
*/
internal class PartWordFinder {
/**
* Searches and return part of a word on the left side of the [cursorPosition] into the [text]
*/
fun getLeadPartOfWord(text: String, cursorPosition: Int): String? {
val charText = text.toCharArray().asList()
val cursorIsOutOfTextRange = cursorPosition > charText.size || cursorPosition < 0
val cursorAtStartOfText = cursorPosition == 0
val hadWhiteSpaceOnLeftOfCursor = charText[cursorPosition - 1].isWhitespace()
val cursorInMiddleOfWord = charText.size > cursorPosition && charText[cursorPosition].isNotWhitespace()
@Suppress("ComplexCondition")
if (cursorIsOutOfTextRange || cursorAtStartOfText || hadWhiteSpaceOnLeftOfCursor || cursorInMiddleOfWord) {
return null
}
for (charIndex in cursorPosition - 1 downTo 0) {
val char = charText[charIndex]
when {
charIndex == 0 -> {
return text.substring(charIndex, cursorPosition)
}
char.isWhitespace() -> {
return text.substring(charIndex + 1, cursorPosition)
}
}
}
return null
}
/**
* Returns a substring of the [word] which excludes the first word part found on the left of [cursorPosition] in
* the [text]
*/
fun getLastPartOfWord(text: String, word: String, cursorPosition: Int): String {
val leadPart = getLeadPartOfWord(text, cursorPosition) ?: return ""
return word.substring(leadPart.length, word.length)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.feature.onboarding.domain
import com.tangem.crypto.bip39.Mnemonic
/**
[REDACTED_AUTHOR]
*/
interface SeedPhraseInteractor {
suspend fun generateMnemonic(): Result<Mnemonic>
suspend fun getMnemonicComponents(): Result<List<String>>
suspend fun isWordMatch(word: String): Boolean
suspend fun validateMnemonicString(text: String): Result<List<String>>
suspend fun getSuggestions(text: String, hasSelection: Boolean, cursorPosition: Int): List<String>
suspend fun insertSuggestionWord(text: String, suggestion: String, cursorPosition: Int): InsertSuggestionResult
companion object {
const val MNEMONIC_DELIMITER = " "
}
}
data class InsertSuggestionResult(
val text: String,
val cursorPosition: Int,
)

View file

@ -0,0 +1,21 @@
package com.tangem.feature.onboarding.domain.di
import com.tangem.feature.onboarding.data.MnemonicRepository
import com.tangem.feature.onboarding.domain.DefaultSeedPhraseInteractor
import com.tangem.feature.onboarding.domain.SeedPhraseInteractor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class OnboardingDomainModule {
@Provides
@Singleton
fun provideSeedPhraseInteractor(repository: MnemonicRepository): SeedPhraseInteractor {
return DefaultSeedPhraseInteractor(repository)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.onboarding.presentation.wallet2.model
/**
[REDACTED_AUTHOR]
*/
data class ButtonState(
val enabled: Boolean = true,
val isClickable: Boolean = true,
val showProgress: Boolean = false,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,8 @@
package com.tangem.feature.onboarding.presentation.wallet2.model
import androidx.annotation.StringRes
data class DescriptionResource(
@StringRes val titleRes: Int,
@StringRes val subTitleRes: Int,
)

View file

@ -0,0 +1,71 @@
package com.tangem.feature.onboarding.presentation.wallet2.model
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.feature.onboarding.domain.SeedPhraseError
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
[REDACTED_AUTHOR]
*/
data class OnboardingSeedPhraseState(
val step: OnboardingSeedPhraseStep,
val introState: IntroState,
val aboutState: AboutState,
val yourSeedPhraseState: YourSeedPhraseState,
val checkSeedPhraseState: CheckSeedPhraseState,
val importSeedPhraseState: ImportSeedPhraseState,
val menuButtonChat: ButtonState,
val isOnboardingFinished: Boolean = false,
)
enum class OnboardingSeedPhraseStep {
Intro, AboutSeedPhrase, YourSeedPhrase, CheckSeedPhrase, ImportSeedPhrase
}
data class IntroState(
val cardImageUrl: String? = null,
val buttonCreateWallet: ButtonState,
val buttonOtherOptions: ButtonState,
)
data class AboutState(
val buttonReadMoreAboutSeedPhrase: ButtonState,
val buttonGenerateSeedPhrase: ButtonState,
val buttonImportSeedPhrase: ButtonState,
)
data class YourSeedPhraseState(
val mnemonicGridItems: ImmutableList<MnemonicGridItem> = persistentListOf(),
val buttonContinue: ButtonState,
)
data class MnemonicGridItem(
val index: Int,
val mnemonic: String,
)
data class CheckSeedPhraseState(
val tvSecondPhrase: TextFieldState,
val tvSeventhPhrase: TextFieldState,
val tvEleventhPhrase: TextFieldState,
val buttonCreateWallet: ButtonState,
)
data class ImportSeedPhraseState(
val tvSeedPhrase: TextFieldState,
val onSuggestedPhraseClick: (Int) -> Unit,
val buttonCreateWallet: ButtonState,
val invalidWords: Set<String> = emptySet(),
val suggestionsList: List<String> = emptyList(),
val error: SeedPhraseError? = null,
)
data class TextFieldState(
val onTextFieldValueChanged: (TextFieldValue) -> Unit,
val textFieldValue: TextFieldValue = TextFieldValue(),
val label: String? = null,
val isError: Boolean = false,
val isFocused: Boolean = false,
val onFocusChanged: (Boolean) -> Unit = {},
)

View file

@ -0,0 +1,56 @@
package com.tangem.feature.onboarding.presentation.wallet2.model
import androidx.compose.ui.text.input.TextFieldValue
/**
[REDACTED_AUTHOR]
*/
data class UiActions(
val introActions: IntroUiAction,
val aboutActions: AboutUiAction,
val yourSeedPhraseActions: YourSeedPhraseUiAction,
val checkSeedPhraseActions: CheckSeedPhraseUiAction,
val importSeedPhraseActions: ImportSeedPhraseUiAction,
val menuChatClick: () -> Unit,
)
data class IntroUiAction(
val buttonCreateWalletClick: () -> Unit,
val buttonOtherOptionsClick: () -> Unit,
)
data class AboutUiAction(
val buttonReadMoreAboutSeedPhraseClick: () -> Unit,
val buttonGenerateSeedPhraseClick: () -> Unit,
val buttonImportSeedPhraseClick: () -> Unit,
)
data class YourSeedPhraseUiAction(
val buttonContinueClick: () -> Unit,
)
data class CheckSeedPhraseUiAction(
val secondTextFieldAction: TextFieldUiAction,
val seventhTextFieldAction: TextFieldUiAction,
val eleventhTextFieldAction: TextFieldUiAction,
val buttonCreateWalletClick: () -> Unit,
)
data class ImportSeedPhraseUiAction(
val phraseTextFieldAction: TextFieldUiAction,
val suggestedPhraseClick: (Int) -> Unit,
val buttonCreateWalletClick: () -> Unit,
)
data class TextFieldUiAction(
val onTextFieldChanged: (TextFieldValue) -> Unit,
val onFocusChanged: (Boolean) -> Unit = {},
)
enum class SeedPhraseField {
Second,
Seventh,
Eleventh,
}

View file

@ -0,0 +1,148 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.AboutState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.Description
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
/**
[REDACTED_AUTHOR]
*/
@Composable
fun AboutSeedPhraseScreen(state: AboutState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.weight(weight = 0.8f),
) {
EditIconBlock()
}
Box(
modifier = Modifier.weight(weight = 1.2f),
) {
Column {
OnboardingDescriptionBlock {
Description(
titleRes = R.string.onboarding_seed_intro_title,
subTitleRes = R.string.onboarding_seed_intro_message,
)
}
ReadMoreBlock(state)
}
}
Box {
OnboardingActionBlock(
firstActionContent = {
SecondaryButton(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_seed_intro_button_generate),
enabled = state.buttonGenerateSeedPhrase.enabled,
showProgress = state.buttonGenerateSeedPhrase.showProgress,
onClick = state.buttonGenerateSeedPhrase.onClick,
)
},
secondActionContent = {
SecondaryButton(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_seed_intro_button_import),
enabled = state.buttonImportSeedPhrase.enabled,
showProgress = state.buttonImportSeedPhrase.showProgress,
onClick = state.buttonImportSeedPhrase.onClick,
)
},
)
}
}
}
@Composable
private fun EditIconBlock() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size44),
painter = painterResource(id = R.drawable.ic_onboarding_text_edit_56),
contentDescription = null,
)
SpacerH32()
Box(
modifier = Modifier
.background(
color = TangemTheme.colors.icon.warning.copy(alpha = 0.12f),
shape = RoundedCornerShape(TangemTheme.dimens.radius8),
),
) {
Text(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.size12,
vertical = TangemTheme.dimens.size4,
),
text = stringResource(id = R.string.onboarding_seed_phrase_intro_legacy),
color = TangemTheme.colors.text.warning,
style = TangemTheme.typography.body1,
)
}
}
}
@Composable
private fun ReadMoreBlock(state: AboutState) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens.size16),
) {
val shape = TangemTheme.shapes.roundedCornersLarge
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(shape)
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.stroke.primary,
shape = shape,
)
.clickable(onClick = state.buttonReadMoreAboutSeedPhrase.onClick)
.padding(
horizontal = TangemTheme.dimens.size16,
vertical = TangemTheme.dimens.size8,
),
) {
Image(
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
contentDescription = null,
)
SpacerW8()
Text(
text = stringResource(id = R.string.onboarding_seed_button_read_more),
)
}
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.OutlineTextField
import com.tangem.core.ui.components.PrimaryButtonIconLeft
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.presentation.wallet2.model.CheckSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.Description
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
/**
[REDACTED_AUTHOR]
*/
@Suppress("ReusedModifierInstance")
@Composable
fun CheckSeedPhraseScreen(state: CheckSeedPhraseState, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize(),
) {
LazyColumn(
modifier = modifier
.weight(1f)
.fillMaxWidth(),
) {
item {
OnboardingDescriptionBlock(
modifier = Modifier
.weight(1f)
.padding(top = TangemTheme.dimens.size48)
.padding(horizontal = TangemTheme.dimens.size16),
) {
Description(
titleRes = R.string.onboarding_seed_user_validation_title,
subTitleRes = R.string.onboarding_seed_user_validation_message,
)
}
}
item {
CheckSeedPhraseBlock(
modifier = Modifier
.imePadding()
.weight(1f)
.padding(top = TangemTheme.dimens.size20)
.padding(horizontal = TangemTheme.dimens.size16),
state = state,
)
}
}
OnboardingActionBlock(
firstActionContent = {
PrimaryButtonIconLeft(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_create_wallet_button_create_wallet),
icon = painterResource(id = R.drawable.ic_tangem_24),
enabled = state.buttonCreateWallet.enabled,
showProgress = state.buttonCreateWallet.showProgress,
onClick = state.buttonCreateWallet.onClick,
)
},
)
}
}
@Composable
private fun CheckSeedPhraseBlock(state: CheckSeedPhraseState, modifier: Modifier = Modifier) {
Column(modifier) {
OutlineTextField(
value = state.tvSecondPhrase.textFieldValue,
onValueChange = state.tvSecondPhrase.onTextFieldValueChanged,
label = state.tvSecondPhrase.label,
isError = state.tvSecondPhrase.isError,
)
OutlineTextField(
value = state.tvSeventhPhrase.textFieldValue,
onValueChange = state.tvSeventhPhrase.onTextFieldValueChanged,
label = state.tvSeventhPhrase.label,
isError = state.tvSeventhPhrase.isError,
)
OutlineTextField(
value = state.tvEleventhPhrase.textFieldValue,
onValueChange = state.tvEleventhPhrase.onTextFieldValueChanged,
label = state.tvEleventhPhrase.label,
isError = state.tvEleventhPhrase.isError,
)
}
}

View file

@ -0,0 +1,140 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.unit.IntOffset
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconLeft
import com.tangem.core.ui.components.TangemTextFieldsDefault
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.ImportSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.DescriptionSubTitleText
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.InvalidWordsColorTransformation
import com.tangem.feature.onboarding.presentation.wallet2.viewmodel.SeedPhraseErrorConverter
/**
[REDACTED_AUTHOR]
*/
@Composable
fun ImportSeedPhraseScreen(state: ImportSeedPhraseState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.weight(1f),
) {
Column {
OnboardingDescriptionBlock(
modifier = Modifier.padding(top = TangemTheme.dimens.size16),
) {
DescriptionSubTitleText(text = stringResource(id = R.string.onboarding_seed_import_message))
}
PhraseBlock(
modifier = Modifier
.padding(top = TangemTheme.dimens.size62)
.padding(horizontal = TangemTheme.dimens.size16),
state = state,
)
SuggestionsBlock(
state = state,
)
}
}
Box {
OnboardingActionBlock(
firstActionContent = {
PrimaryButtonIconLeft(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_create_wallet_button_create_wallet),
icon = painterResource(id = R.drawable.ic_tangem_24),
enabled = state.buttonCreateWallet.enabled,
showProgress = state.buttonCreateWallet.showProgress,
onClick = state.buttonCreateWallet.onClick,
)
},
)
}
}
}
@Composable
private fun PhraseBlock(state: ImportSeedPhraseState, modifier: Modifier = Modifier) {
// TODO: replace by TextReference
val errorConverter = remember { SeedPhraseErrorConverter() }
Column(
modifier = modifier.fillMaxWidth(),
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size142),
value = state.tvSeedPhrase.textFieldValue,
onValueChange = state.tvSeedPhrase.onTextFieldValueChanged,
textStyle = TangemTheme.typography.body1,
singleLine = false,
visualTransformation = InvalidWordsColorTransformation(
wordsToBrush = state.invalidWords,
style = SpanStyle(color = TangemTheme.colors.text.warning),
),
colors = TangemTextFieldsDefault.defaultTextFieldColors,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size32),
) {
val message = errorConverter.convert(LocalContext.current to state.error)
if (message != null) {
Text(
modifier = Modifier.fillMaxSize(),
text = message,
style = TangemTheme.typography.caption.copy(
color = TangemTheme.colors.text.warning,
),
)
}
}
}
}
@Suppress("ReusedModifierInstance")
@Composable
private fun SuggestionsBlock(state: ImportSeedPhraseState, modifier: Modifier = Modifier) {
AnimatedVisibility(
enter = fadeIn() + slideIn(initialOffset = { IntOffset(x = 200, y = 0) }),
exit = slideOut(targetOffset = { IntOffset(x = -200, y = 0) }) + fadeOut(),
visible = state.suggestionsList.isNotEmpty(),
) {
LazyRow(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.size16),
) {
items(state.suggestionsList.size) { index ->
PrimaryButton(
modifier = Modifier
.height(TangemTheme.dimens.size46)
.padding(all = TangemTheme.dimens.size4),
text = state.suggestionsList[index],
onClick = { state.onSuggestedPhraseClick(index) },
)
}
}
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.PrimaryButtonIconLeft
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.IntroState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.Description
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
/**
[REDACTED_AUTHOR]
*/
@Composable
fun IntroScreen(state: IntroState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.weight(weight = 1.5f),
) {
CardImageBlock(state.cardImageUrl)
}
Box(
modifier = Modifier.weight(weight = 1f),
) {
OnboardingDescriptionBlock {
Description(
titleRes = R.string.onboarding_create_wallet_options_title,
subTitleRes = R.string.onboarding_create_wallet_options_message,
)
}
}
Box {
OnboardingActionBlock(
firstActionContent = {
PrimaryButtonIconLeft(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_create_wallet_button_create_wallet),
icon = painterResource(id = R.drawable.ic_tangem_24),
enabled = state.buttonCreateWallet.enabled,
showProgress = state.buttonCreateWallet.showProgress,
onClick = state.buttonCreateWallet.onClick,
)
},
secondActionContent = {
SecondaryButton(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.onboarding_create_wallet_options_button_options),
enabled = state.buttonOtherOptions.enabled,
showProgress = state.buttonOtherOptions.showProgress,
onClick = state.buttonOtherOptions.onClick,
)
},
)
}
}
}
@Composable
private fun CardImageBlock(cardImageUrl: String? = null) {
Box(modifier = Modifier.fillMaxSize()) {
val cardImageModifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
SubcomposeAsyncImage(
modifier = cardImageModifier.padding(horizontal = TangemTheme.dimens.size34),
model = ImageRequest.Builder(LocalContext.current)
.data(cardImageUrl)
.crossfade(true)
.build(),
loading = { CardPlaceHolder(cardImageModifier) },
error = { CardPlaceHolder(cardImageModifier) },
contentDescription = null,
)
}
}
@Composable
private fun CardPlaceHolder(modifier: Modifier = Modifier) {
Image(
modifier = modifier,
painter = painterResource(id = R.drawable.card_placeholder_black),
contentScale = ContentScale.Fit,
contentDescription = null,
)
}

View file

@ -0,0 +1,91 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerW32
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.presentation.wallet2.model.MnemonicGridItem
import com.tangem.feature.onboarding.presentation.wallet2.model.YourSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.Description
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingActionBlock
import com.tangem.feature.onboarding.presentation.wallet2.ui.components.OnboardingDescriptionBlock
import kotlinx.collections.immutable.ImmutableList
/**
[REDACTED_AUTHOR]
*/
@Composable
fun YourSeedPhraseScreen(state: YourSeedPhraseState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.weight(weight = 0.6f),
) {
OnboardingDescriptionBlock(
modifier = Modifier.align(Alignment.Center),
) {
Description(
titleRes = R.string.onboarding_seed_generate_title,
subTitleRes = R.string.onboarding_seed_generate_message,
)
}
}
Box(
modifier = Modifier.weight(weight = 1f),
) {
PhraseGreedBlock(state.mnemonicGridItems)
}
Box {
OnboardingActionBlock(
firstActionContent = {
PrimaryButton(
modifier = Modifier
.fillMaxWidth(),
text = stringResource(id = R.string.common_continue),
onClick = state.buttonContinue.onClick,
)
},
)
}
}
}
@Composable
private fun PhraseGreedBlock(mnemonicGridItems: ImmutableList<MnemonicGridItem>) {
LazyVerticalGrid(
columns = GridCells.Fixed(count = 2),
) {
this.items(
count = mnemonicGridItems.size,
) { index ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
val item = mnemonicGridItems[index]
SpacerW32()
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.res.TangemTheme
/**
[REDACTED_AUTHOR]
*/
@Composable
fun OnboardingActionBlock(
firstActionContent: @Composable (() -> Unit)? = null,
secondActionContent: @Composable (() -> Unit)? = null,
) {
if (firstActionContent == null && secondActionContent == null) return
Box(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.size16)
.padding(
top = TangemTheme.dimens.size8,
bottom = TangemTheme.dimens.size32,
),
) {
Column(
modifier = Modifier.align(Alignment.BottomCenter),
) {
firstActionContent?.invoke()
if (firstActionContent != null && secondActionContent != null) SpacerH12()
secondActionContent?.invoke()
}
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui.components
import androidx.annotation.StringRes
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.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.onboarding.presentation.wallet2.model.DescriptionResource
import kotlinx.collections.immutable.ImmutableList
/**
* UI component witch provides single and carousel description for the onboarding process
*/
@Composable
fun OnboardingDescriptionBlock(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.size24),
) {
content()
}
}
@Composable
fun Description(@StringRes titleRes: Int, @StringRes subTitleRes: Int) {
Column {
DescriptionTitleText(text = stringResource(id = titleRes))
SpacerH12()
DescriptionSubTitleText(text = stringResource(id = subTitleRes))
}
}
@Suppress("UnusedPrivateMember")
@Composable
fun OnboardingCarouselDescriptionBlock(
descriptionsList: ImmutableList<DescriptionResource>,
modifier: Modifier = Modifier,
) {
OnboardingDescriptionBlock(modifier) {
// TODO: implement
}
}
@Composable
fun DescriptionTitleText(text: String) {
Text(
text = text,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
fun DescriptionSubTitleText(text: String) {
Text(
text = text,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -0,0 +1,77 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui.stateBuiders
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.feature.onboarding.presentation.wallet2.model.OnboardingSeedPhraseState
import com.tangem.feature.onboarding.presentation.wallet2.model.SeedPhraseField
/**
[REDACTED_AUTHOR]
*/
class CheckSeedPhraseStateBuilder {
fun updateTextField(
uiState: OnboardingSeedPhraseState,
field: SeedPhraseField,
textFieldValue: TextFieldValue,
): OnboardingSeedPhraseState = when (field) {
SeedPhraseField.Second -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvSecondPhrase = uiState.checkSeedPhraseState.tvSecondPhrase.copy(
textFieldValue = textFieldValue,
),
),
)
SeedPhraseField.Seventh -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvSeventhPhrase = uiState.checkSeedPhraseState.tvSeventhPhrase.copy(
textFieldValue = textFieldValue,
),
),
)
SeedPhraseField.Eleventh -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvEleventhPhrase = uiState.checkSeedPhraseState.tvEleventhPhrase.copy(
textFieldValue = textFieldValue,
),
),
)
}
fun updateTextFieldError(
uiState: OnboardingSeedPhraseState,
field: SeedPhraseField,
hasError: Boolean,
): OnboardingSeedPhraseState = when (field) {
SeedPhraseField.Second -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvSecondPhrase = uiState.checkSeedPhraseState.tvSecondPhrase.copy(
isError = hasError,
),
),
)
SeedPhraseField.Seventh -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvSeventhPhrase = uiState.checkSeedPhraseState.tvSeventhPhrase.copy(
isError = hasError,
),
),
)
SeedPhraseField.Eleventh -> uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
tvEleventhPhrase = uiState.checkSeedPhraseState.tvEleventhPhrase.copy(
isError = hasError,
),
),
)
}
fun updateCreateWalletButton(uiState: OnboardingSeedPhraseState, enabled: Boolean): OnboardingSeedPhraseState {
return uiState.copy(
checkSeedPhraseState = uiState.checkSeedPhraseState.copy(
buttonCreateWallet = uiState.checkSeedPhraseState.buttonCreateWallet.copy(
enabled = enabled,
),
),
)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui.stateBuiders
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.feature.onboarding.domain.InsertSuggestionResult
import com.tangem.feature.onboarding.domain.SeedPhraseError
import com.tangem.feature.onboarding.presentation.wallet2.model.OnboardingSeedPhraseState
/**
[REDACTED_AUTHOR]
*/
class ImportSeedPhraseStateBuilder {
fun updateTextField(
uiState: OnboardingSeedPhraseState,
textFieldValue: TextFieldValue,
): OnboardingSeedPhraseState = uiState.copy(
importSeedPhraseState = uiState.importSeedPhraseState.copy(
tvSeedPhrase = uiState.importSeedPhraseState.tvSeedPhrase.copy(
textFieldValue = textFieldValue,
),
),
)
fun updateCreateWalletButton(uiState: OnboardingSeedPhraseState, enabled: Boolean): OnboardingSeedPhraseState {
return uiState.copy(
importSeedPhraseState = uiState.importSeedPhraseState.copy(
buttonCreateWallet = uiState.importSeedPhraseState.buttonCreateWallet.copy(
enabled = enabled,
),
),
)
}
fun updateInvalidWords(uiState: OnboardingSeedPhraseState, invalidWords: Set<String>): OnboardingSeedPhraseState =
uiState.copy(
importSeedPhraseState = uiState.importSeedPhraseState.copy(
invalidWords = invalidWords,
),
)
fun updateSuggestions(uiState: OnboardingSeedPhraseState, suggestions: List<String>): OnboardingSeedPhraseState =
uiState.copy(
importSeedPhraseState = uiState.importSeedPhraseState.copy(
suggestionsList = suggestions,
),
)
fun insertSuggestionWord(
uiState: OnboardingSeedPhraseState,
insertResult: InsertSuggestionResult,
): OnboardingSeedPhraseState {
val newTextFieldValue = uiState.importSeedPhraseState.tvSeedPhrase.textFieldValue.copy(
text = insertResult.text,
selection = TextRange(insertResult.cursorPosition, insertResult.cursorPosition),
)
return updateTextField(uiState, newTextFieldValue)
}
fun updateError(uiState: OnboardingSeedPhraseState, error: SeedPhraseError?): OnboardingSeedPhraseState {
return uiState.copy(
importSeedPhraseState = uiState.importSeedPhraseState.copy(
error = error,
),
)
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.feature.onboarding.presentation.wallet2.ui.stateBuiders
import com.tangem.feature.onboarding.presentation.wallet2.model.*
import kotlinx.collections.immutable.ImmutableList
/**
[REDACTED_AUTHOR]
*/
class StateBuilder(
private val uiActions: UiActions,
) {
val checkSeedPhrase: CheckSeedPhraseStateBuilder = CheckSeedPhraseStateBuilder()
val importSeedPhrase: ImportSeedPhraseStateBuilder = ImportSeedPhraseStateBuilder()
fun init(): OnboardingSeedPhraseState = OnboardingSeedPhraseState(
step = OnboardingSeedPhraseStep.Intro,
introState = IntroState(
buttonCreateWallet = ButtonState(
onClick = uiActions.introActions.buttonCreateWalletClick,
),
buttonOtherOptions = ButtonState(
onClick = uiActions.introActions.buttonOtherOptionsClick,
),
),
aboutState = AboutState(
buttonReadMoreAboutSeedPhrase = ButtonState(
onClick = uiActions.aboutActions.buttonReadMoreAboutSeedPhraseClick,
),
buttonGenerateSeedPhrase = ButtonState(
onClick = uiActions.aboutActions.buttonGenerateSeedPhraseClick,
),
buttonImportSeedPhrase = ButtonState(
onClick = uiActions.aboutActions.buttonImportSeedPhraseClick,
),
),
yourSeedPhraseState = YourSeedPhraseState(
buttonContinue = ButtonState(
onClick = uiActions.yourSeedPhraseActions.buttonContinueClick,
),
),
checkSeedPhraseState = CheckSeedPhraseState(
tvSecondPhrase = TextFieldState(
label = "2",
isFocused = true,
onTextFieldValueChanged = uiActions.checkSeedPhraseActions.secondTextFieldAction.onTextFieldChanged,
onFocusChanged = uiActions.checkSeedPhraseActions.secondTextFieldAction.onFocusChanged,
),
tvSeventhPhrase = TextFieldState(
label = "7",
isFocused = false,
onTextFieldValueChanged = uiActions.checkSeedPhraseActions.seventhTextFieldAction.onTextFieldChanged,
onFocusChanged = uiActions.checkSeedPhraseActions.seventhTextFieldAction.onFocusChanged,
),
tvEleventhPhrase = TextFieldState(
label = "11",
isFocused = false,
onTextFieldValueChanged = uiActions.checkSeedPhraseActions.eleventhTextFieldAction.onTextFieldChanged,
onFocusChanged = uiActions.checkSeedPhraseActions.eleventhTextFieldAction.onFocusChanged,
),
buttonCreateWallet = ButtonState(
enabled = false,
onClick = uiActions.checkSeedPhraseActions.buttonCreateWalletClick,
),
),
importSeedPhraseState = ImportSeedPhraseState(
tvSeedPhrase = TextFieldState(
onTextFieldValueChanged = uiActions.importSeedPhraseActions.phraseTextFieldAction.onTextFieldChanged,
onFocusChanged = uiActions.importSeedPhraseActions.phraseTextFieldAction.onFocusChanged,
),
onSuggestedPhraseClick = uiActions.importSeedPhraseActions.suggestedPhraseClick,
buttonCreateWallet = ButtonState(
enabled = false,
onClick = uiActions.importSeedPhraseActions.buttonCreateWalletClick,
),
),
menuButtonChat = ButtonState(
onClick = uiActions.menuChatClick,
),
)
fun changeStep(uiState: OnboardingSeedPhraseState, step: OnboardingSeedPhraseStep): OnboardingSeedPhraseState =
uiState.copy(
step = step,
)
fun generateMnemonicComponents(uiState: OnboardingSeedPhraseState): OnboardingSeedPhraseState {
return uiState.copy(
aboutState = uiState.aboutState.copy(
buttonGenerateSeedPhrase = uiState.aboutState.buttonGenerateSeedPhrase.copy(
showProgress = true,
),
buttonImportSeedPhrase = uiState.aboutState.buttonImportSeedPhrase.copy(
enabled = false,
),
),
)
}
fun mnemonicGenerated(
uiState: OnboardingSeedPhraseState,
mnemonicGridItems: ImmutableList<MnemonicGridItem>,
): OnboardingSeedPhraseState {
return updateMnemonicComponents(uiState, mnemonicGridItems)
.copy(
step = OnboardingSeedPhraseStep.YourSeedPhrase,
aboutState = uiState.aboutState.copy(
buttonGenerateSeedPhrase = uiState.aboutState.buttonGenerateSeedPhrase.copy(
showProgress = false,
),
buttonImportSeedPhrase = uiState.aboutState.buttonImportSeedPhrase.copy(
enabled = true,
),
),
)
}
fun updateMnemonicComponents(
uiState: OnboardingSeedPhraseState,
mnemonicGridItems: ImmutableList<MnemonicGridItem>,
): OnboardingSeedPhraseState = uiState.copy(
yourSeedPhraseState = uiState.yourSeedPhraseState.copy(
mnemonicGridItems = mnemonicGridItems,
),
)
}

View file

@ -0,0 +1,79 @@
package com.tangem.feature.onboarding.presentation.wallet2.viewmodel
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import com.tangem.utils.extensions.isSingleItem
/**
[REDACTED_AUTHOR]
*/
class InvalidWordsColorTransformation(
private val wordsToBrush: Set<String>,
private val style: SpanStyle,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val plainText = text.text
if (wordsToBrush.isSingleItem() && wordsToBrush.first() == plainText) {
return plainText.annotate().toTransformedText()
}
val wordList = makeSeparatedWordlist(plainText)
val annotatedString = makeAnnotatedString(wordList)
return annotatedString.toTransformedText()
}
private fun makeSeparatedWordlist(plainText: String): List<String> {
val plainTextCharArray = plainText.toCharArray()
val wordlist = mutableListOf<String>()
var wordBuilder = StringBuilder()
for (index in plainTextCharArray.indices) {
val char = plainTextCharArray[index]
if (char.isWhitespace()) {
if (wordBuilder.isEmpty()) {
wordlist.add(WORD_SEPARATOR)
} else {
wordlist.add(wordBuilder.toString())
wordBuilder = StringBuilder()
wordlist.add(WORD_SEPARATOR)
}
} else {
if (index == plainTextCharArray.lastIndex) {
wordBuilder.append(char)
wordlist.add(wordBuilder.toString())
wordBuilder = StringBuilder()
} else {
wordBuilder.append(char)
}
}
}
return wordlist.toList()
}
private fun makeAnnotatedString(wordList: List<String>): AnnotatedString = buildAnnotatedString {
wordList.forEach {
if (it == WORD_SEPARATOR) {
append(WHITE_SPACE)
} else if (wordsToBrush.contains(it)) {
append(it.annotate())
} else {
append(it)
}
}
}
private fun String.annotate(): AnnotatedString = AnnotatedString(this, style)
private fun AnnotatedString.toTransformedText(): TransformedText = TransformedText(this, OffsetMapping.Identity)
companion object {
private const val WHITE_SPACE = " "
private const val WORD_SEPARATOR = "_$@-*"
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.onboarding.presentation.wallet2.viewmodel
import android.content.Context
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.feature.onboarding.R
import com.tangem.feature.onboarding.domain.SeedPhraseError
/**
[REDACTED_AUTHOR]
*/
class SeedPhraseErrorConverter : ModuleMessageConverter<Pair<Context, SeedPhraseError?>, String?> {
override fun convert(source: Pair<Context, SeedPhraseError?>): String? {
val (context, message) = source
val convertedMessage = when (message) {
SeedPhraseError.InvalidEntropyLength -> {
context.getString(R.string.onboarding_seed_mnemonic_invalid_checksum)
}
is SeedPhraseError.InvalidWords -> {
context.getString(R.string.onboarding_seed_mnemonic_wrong_words)
}
else -> null
}
return convertedMessage
}
}

View file

@ -0,0 +1,318 @@
package com.tangem.feature.onboarding.presentation.wallet2.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.onboarding.domain.SeedPhraseError
import com.tangem.feature.onboarding.domain.SeedPhraseInteractor
import com.tangem.feature.onboarding.presentation.wallet2.model.*
import com.tangem.feature.onboarding.presentation.wallet2.ui.stateBuiders.StateBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.extensions.isEven
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
/**
[REDACTED_AUTHOR]
*/
@HiltViewModel
class SeedPhraseViewModel @Inject constructor(
private val interactor: SeedPhraseInteractor,
private val dispatchers: CoroutineDispatcherProvider,
) : ViewModel() {
private var uiBuilder = StateBuilder(createUiActions())
var uiState: OnboardingSeedPhraseState by mutableStateOf(uiBuilder.init())
private set
val currentStep: OnboardingSeedPhraseStep
get() = uiState.step
private val textFieldsDebouncers = mutableMapOf<String, Debouncer>()
private var suggestionWordInserted: AtomicBoolean = AtomicBoolean(false)
override fun onCleared() {
textFieldsDebouncers.forEach { entry -> entry.value.release() }
textFieldsDebouncers.clear()
super.onCleared()
}
private fun createUiActions(): UiActions = UiActions(
introActions = IntroUiAction(
buttonCreateWalletClick = ::buttonCreateWalletClick,
buttonOtherOptionsClick = ::buttonOtherOptionsClick,
),
aboutActions = AboutUiAction(
buttonReadMoreAboutSeedPhraseClick = ::buttonReadMoreAboutSeedPhraseClick,
buttonGenerateSeedPhraseClick = ::buttonGenerateSeedPhraseClick,
buttonImportSeedPhraseClick = ::buttonImportSeedPhraseClick,
),
yourSeedPhraseActions = YourSeedPhraseUiAction(
buttonContinueClick = ::buttonContinueClick,
),
checkSeedPhraseActions = CheckSeedPhraseUiAction(
buttonCreateWalletClick = ::buttonCreateWalletWithSeedPhraseClick,
secondTextFieldAction = TextFieldUiAction(
onTextFieldChanged = { value -> onTextFieldChanged(SeedPhraseField.Second, value) },
),
seventhTextFieldAction = TextFieldUiAction(
onTextFieldChanged = { value -> onTextFieldChanged(SeedPhraseField.Seventh, value) },
),
eleventhTextFieldAction = TextFieldUiAction(
onTextFieldChanged = { value -> onTextFieldChanged(SeedPhraseField.Eleventh, value) },
),
),
importSeedPhraseActions = ImportSeedPhraseUiAction(
phraseTextFieldAction = TextFieldUiAction(
onTextFieldChanged = { value -> onSeedPhraseTextFieldChanged(value) },
),
suggestedPhraseClick = ::buttonSuggestedPhraseClick,
buttonCreateWalletClick = ::buttonCreateWalletWithSeedPhraseClick,
),
menuChatClick = ::menuChatClick,
)
// region CheckSeedPhrase
private fun onTextFieldChanged(field: SeedPhraseField, textFieldValue: TextFieldValue) {
viewModelScope.launchSingle {
updateUi { uiBuilder.checkSeedPhrase.updateTextField(uiState, field, textFieldValue) }
val fieldState = field.getState(uiState)
if (fieldState.textFieldValue.text.isEmpty()) {
updateUi { uiBuilder.checkSeedPhrase.updateTextFieldError(uiState, field, hasError = false) }
return@launchSingle
}
createOrGetDebouncer(field.name).debounce(viewModelScope, context = dispatchers.io) {
val hasError = !interactor.isWordMatch(textFieldValue.text)
if (fieldState.isError != hasError) {
updateUi { uiBuilder.checkSeedPhrase.updateTextFieldError(uiState, field, hasError) }
}
val allFieldsWithoutError = SeedPhraseField.values()
.map { field -> field.getState(uiState) }
.all { fieldState -> !fieldState.isError }
if (uiState.checkSeedPhraseState.buttonCreateWallet.enabled != allFieldsWithoutError) {
updateUi { uiBuilder.checkSeedPhrase.updateCreateWalletButton(uiState, allFieldsWithoutError) }
}
}
}
}
// endregion CheckSeedPhrase
// region ImportSeedPhrase
private fun onSeedPhraseTextFieldChanged(textFieldValue: TextFieldValue) {
val oldTextFieldValue = uiState.importSeedPhraseState.tvSeedPhrase.textFieldValue
val isSameText = textFieldValue.text == oldTextFieldValue.text
val isCursorMoved = textFieldValue.selection != oldTextFieldValue.selection
val mediateState = uiBuilder.importSeedPhrase.updateTextField(uiState, textFieldValue)
uiState = uiBuilder.importSeedPhrase.updateCreateWalletButton(mediateState, enabled = false)
val fieldState = uiState.importSeedPhraseState.tvSeedPhrase
val inputMnemonic = fieldState.textFieldValue.text
val debouncer = createOrGetDebouncer(MNEMONIC_DEBOUNCER)
when {
suggestionWordInserted.getAndSet(false) -> {
debouncer.debounce(viewModelScope, MNEMONIC_DEBOUNCE_DELAY, dispatchers.single) {
validateMnemonic(inputMnemonic)
}
}
isSameText && !isCursorMoved -> {
// do nothing
}
isSameText && isCursorMoved -> {
debouncer.debounce(viewModelScope, MNEMONIC_DEBOUNCE_DELAY, dispatchers.single) {
updateSuggestions(fieldState)
}
}
else -> {
debouncer.debounce(viewModelScope, MNEMONIC_DEBOUNCE_DELAY, dispatchers.single) {
updateSuggestions(fieldState)
validateMnemonic(inputMnemonic)
}
}
}
}
private suspend fun validateMnemonic(inputMnemonic: String) {
if (inputMnemonic.isEmpty() && uiState.importSeedPhraseState.error != null) {
updateUi { uiBuilder.importSeedPhrase.updateError(uiState, null) }
return
}
interactor.validateMnemonicString(inputMnemonic)
.onSuccess {
updateUi { uiBuilder.importSeedPhrase.updateError(uiState, null) }
}
.onFailure {
val error = it as? SeedPhraseError ?: return
val mediateState = when (error) {
is SeedPhraseError.InvalidWords -> {
uiBuilder.importSeedPhrase.updateInvalidWords(uiState, error.words)
}
else -> uiState
}
updateUi { uiBuilder.importSeedPhrase.updateError(mediateState, error) }
}
}
private suspend fun updateSuggestions(fieldState: TextFieldState) {
val suggestions = interactor.getSuggestions(
text = fieldState.textFieldValue.text,
hasSelection = !fieldState.textFieldValue.selection.collapsed,
cursorPosition = fieldState.textFieldValue.selection.end,
)
updateUi { uiBuilder.importSeedPhrase.updateSuggestions(uiState, suggestions) }
}
// endregion ImportSeedPhrase
// region ButtonClickHandlers
@Suppress("EmptyFunctionBlock")
private fun buttonCreateWalletClick() {
}
@Suppress("EmptyFunctionBlock")
private fun buttonCreateWalletWithSeedPhraseClick() {
}
private fun buttonOtherOptionsClick() {
viewModelScope.launchSingle {
updateUi { uiBuilder.changeStep(uiState, OnboardingSeedPhraseStep.AboutSeedPhrase) }
}
}
private fun buttonReadMoreAboutSeedPhraseClick() {
// TODO: open the about info through a router
}
private fun buttonGenerateSeedPhraseClick() {
viewModelScope.launchSingle {
updateUi { uiBuilder.generateMnemonicComponents(uiState) }
delay(DELAY_GENERATE_SEED_PHRASE)
interactor.generateMnemonic()
.onSuccess { mnemonic ->
val mnemonicGridItems = generateMnemonicGridList(mnemonic.mnemonicComponents)
updateUi { uiBuilder.mnemonicGenerated(uiState, mnemonicGridItems.toImmutableList()) }
}
.onFailure {
// TODO: show error
}
}
}
private suspend fun generateMnemonicGridList(mnemonicComponents: List<String>): ImmutableList<MnemonicGridItem> {
val size = mnemonicComponents.size
val splitIndex = if (size.isEven()) size / 2 else size / 2 + 1
val leftColumn = mnemonicComponents.subList(0, splitIndex)
val rightColumn = mnemonicComponents.subList(splitIndex, size)
val mnemonicGridItems = mutableListOf<MnemonicGridItem>()
for (index in 0 until splitIndex) {
if (index <= leftColumn.size) {
val item = MnemonicGridItem(
index = index + 1,
mnemonic = leftColumn[index],
)
mnemonicGridItems.add(item)
}
if (index < rightColumn.size) {
val item = MnemonicGridItem(
index = index + splitIndex + 1,
mnemonic = rightColumn[index],
)
mnemonicGridItems.add(item)
}
}
return mnemonicGridItems.toImmutableList()
}
private fun buttonImportSeedPhraseClick() {
viewModelScope.launchSingle {
updateUi { uiBuilder.changeStep(uiState, OnboardingSeedPhraseStep.ImportSeedPhrase) }
}
}
private fun buttonContinueClick() {
viewModelScope.launchSingle {
updateUi { uiBuilder.changeStep(uiState, OnboardingSeedPhraseStep.CheckSeedPhrase) }
}
}
private fun buttonSuggestedPhraseClick(suggestionIndex: Int) {
viewModelScope.launchSingle {
suggestionWordInserted.set(true)
val textFieldValue = uiState.importSeedPhraseState.tvSeedPhrase.textFieldValue
val word = uiState.importSeedPhraseState.suggestionsList[suggestionIndex]
val cursorPosition = textFieldValue.selection.end
val insertResult = interactor.insertSuggestionWord(
text = textFieldValue.text,
suggestion = word,
cursorPosition = cursorPosition,
)
updateUi {
val mediateState = uiBuilder.importSeedPhrase.insertSuggestionWord(uiState, insertResult)
uiBuilder.importSeedPhrase.updateSuggestions(mediateState, emptyList())
}
}
}
private fun menuChatClick() {
// TODO: open the support chat through a router
}
// endregion ButtonClickHandlers
// region Utils
/**
* Updating the UI with a contract where all copying of objects are called in the Single thread context and
* updating the UiState in the main context.
*/
private suspend fun updateUi(updateBlock: suspend () -> OnboardingSeedPhraseState) {
withSingleContext {
val newState = updateBlock.invoke()
withMainContext { uiState = newState }
}
}
private fun createOrGetDebouncer(name: String): Debouncer {
return textFieldsDebouncers[name] ?: Debouncer().apply { textFieldsDebouncers[name] = this }
}
private fun SeedPhraseField.getState(uiState: OnboardingSeedPhraseState): TextFieldState = when (this) {
SeedPhraseField.Second -> uiState.checkSeedPhraseState.tvSecondPhrase
SeedPhraseField.Seventh -> uiState.checkSeedPhraseState.tvSeventhPhrase
SeedPhraseField.Eleventh -> uiState.checkSeedPhraseState.tvEleventhPhrase
}
private fun CoroutineScope.launchSingle(block: suspend CoroutineScope.() -> Unit): Job {
return viewModelScope.launch(dispatchers.single, block = block)
}
private suspend fun <T> withMainContext(block: suspend CoroutineScope.() -> T): T {
return withContext(dispatchers.main, block)
}
private suspend fun <T> withSingleContext(block: suspend CoroutineScope.() -> T): T {
return withContext(dispatchers.single, block)
}
// endregion Utils
companion object {
private const val MNEMONIC_DEBOUNCER = "MnemonicDebouncer"
private const val MNEMONIC_DEBOUNCE_DELAY = 700L
private const val DELAY_GENERATE_SEED_PHRASE = 300L
}
}

View file

@ -359,7 +359,7 @@ internal class SwapViewModel @Inject constructor(
lastAmount.value = cutValue
uiState =
stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals))
amountDebouncer.debounce(DEBOUNCE_AMOUNT_DELAY, viewModelScope) {
amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) {
startLoadingQuotes(fromToken, toToken, lastAmount.value)
}
}

View file

@ -20,6 +20,7 @@ internal fun BaseExtension.configureCompilerOptions() {
internal fun BaseExtension.configureCompose(project: Project) {
val useCompose = with(project.path) {
contains(":ui") ||
contains(":onboarding") ||
contains(":presentation") ||
contains(":app") || // TODO: [REDACTED_JIRA]
contains(":tester:impl") // TODO: Rename module

View file

@ -46,6 +46,7 @@ include(":libs:auth")
// endregion Libs modules
// region Feature modules
include(":features:onboarding")
include(":features:referral:data")
include(":features:referral:domain")
include(":features:referral:presentation")