implementation of push and hold to send zec (#641)

* [#249] Implement Press-And-Hold To Confirm Sending ZEC

* Remove default value for interaction source

* Rename interaction source argument

* removed dependency of ui-design-lib on sdkExtLib

Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Alex 2022-12-02 10:54:06 +01:00 committed by GitHub
parent 6c6339fb41
commit 9150f80b9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 243 additions and 37 deletions

View File

@ -6,8 +6,6 @@
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />

View File

@ -0,0 +1,93 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.material3.Text
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.testTimeSource
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
class ButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
@Test
@MediumTest
fun timedButtonTest(): Unit = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val testSetup = newTestSetup(testDispatcher, 2.seconds)
val mark = testTimeSource.markNow()
launch(Dispatchers.Main) {
testSetup.interactionSource.emit(PressInteraction.Press(Offset.Zero))
advanceTimeBy(3.seconds.inWholeMilliseconds)
}
launch {
testSetup.mutableActionExecuted.collect {
if (!it) return@collect
assertTrue { mark.elapsedNow() >= 2.seconds }
this.cancel()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
@MediumTest
fun buttonClickTest() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val testSetup = newTestSetup(testDispatcher, 2.seconds)
composeTestRule.onNodeWithText("button").also {
it.performClick()
}
advanceTimeBy(3.seconds.inWholeMilliseconds)
assertFalse { testSetup.mutableActionExecuted.value }
}
private fun newTestSetup(testDispatcher: CoroutineDispatcher, duration: Duration) = TestSetup(testDispatcher, composeTestRule, duration)
private class TestSetup(coroutineDispatcher: CoroutineDispatcher, composeTestRule: ComposeContentTestRule, duration: Duration) {
val mutableActionExecuted = MutableStateFlow(false)
val interactionSource = MutableInteractionSource()
init {
composeTestRule.setContent {
ZcashTheme {
TimedButton(
duration = duration,
onClick = { mutableActionExecuted.update { true } },
coroutineDispatcher = coroutineDispatcher,
content = { Text(text = "button") },
interactionSource = interactionSource
)
}
}
}
}
}

View File

@ -1,6 +1,9 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
@ -9,10 +12,20 @@ import androidx.compose.material3.ButtonDefaults.buttonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Preview
@Composable
@ -145,3 +158,41 @@ fun DangerousButton(
)
}
}
@Suppress("LongParameterList")
@Composable
fun TimedButton(
modifier: Modifier = Modifier,
duration: Duration = 5.seconds,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit
) {
LaunchedEffect(interactionSource) {
var action: Job? = null
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
action = launch(coroutineDispatcher) {
delay(duration)
withContext(Dispatchers.Main) {
onClick()
}
}
}
is PressInteraction.Release -> {
action?.cancel()
}
}
}
}
Button(
modifier = modifier,
onClick = {},
interactionSource = interactionSource,
content = content
)
}

View File

@ -1,5 +1,8 @@
package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.ComposeContentTestRule
@ -10,6 +13,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
import cash.z.ecc.sdk.fixture.MemoFixture
@ -22,7 +26,13 @@ import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -60,15 +70,24 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.setValidAddress()
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
clickConfirmation(testSetup.interactionSource)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.mutableActionExecuted.collectWith(this) {
if (!it) return@collectWith
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345600000), it.amount)
assertTrue(it.memo.value.isEmpty())
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345678900000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
}
}
this.cancel()
}
}
}
@ -87,15 +106,24 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
clickConfirmation(testSetup.interactionSource)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.mutableActionExecuted.collectWith(this) {
if (!it) return@collectWith
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345600000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345678900000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
}
}
this.cancel()
}
}
}
@ -136,15 +164,24 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
clickConfirmation(testSetup.interactionSource)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.mutableActionExecuted.collectWith(this) {
if (!it) return@collectWith
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345678900000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345678900000), it.amount)
assertEquals(ZecRequestFixture.MESSAGE.value, it.memo.value)
}
}
this.cancel()
}
}
}
@ -205,15 +242,24 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.clickCreateAndSend()
composeTestRule.assertOnConfirmation()
composeTestRule.clickConfirmation()
clickConfirmation(testSetup.interactionSource)
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.mutableActionExecuted.collectWith(this) {
if (!it) return@collectWith
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345600000), it.amount)
assertTrue(it.memo.value.isEmpty())
assertEquals(1, testSetup.getOnCreateCount())
launch {
testSetup.getLastSend().also {
assertNotNull(it)
assertEquals(WalletAddressFixture.unified(), it.destination)
assertEquals(Zatoshi(12345600000), it.amount)
assertTrue(it.memo.value.isEmpty())
}
}
this.cancel()
}
}
}
@ -250,6 +296,8 @@ class SendViewTest : UiTestPrerequisites() {
private val onBackCount = AtomicInteger(0)
private val onCreateCount = AtomicInteger(0)
val interactionSource = MutableInteractionSource()
val mutableActionExecuted = MutableStateFlow(false)
@Volatile
private var onSendZecRequest: ZecSend? = null
@ -274,12 +322,14 @@ class SendViewTest : UiTestPrerequisites() {
ZcashTheme {
Send(
mySpendableBalance = ZatoshiFixture.new(),
pressAndHoldInteractionSource = interactionSource,
goBack = {
onBackCount.incrementAndGet()
},
onCreateAndSend = {
onCreateCount.incrementAndGet()
onSendZecRequest = it
mutableActionExecuted.update { true }
}
)
}
@ -336,9 +386,10 @@ private fun ComposeContentTestRule.clickCreateAndSend() {
}
}
private fun ComposeContentTestRule.clickConfirmation() {
onNodeWithText(getStringResource(R.string.send_confirm)).also {
it.performClick()
@OptIn(ExperimentalCoroutinesApi::class)
private fun TestScope.clickConfirmation(interactionSource: MutableInteractionSource) {
launch(Dispatchers.Main) {
interactionSource.emit(PressInteraction.Press(Offset.Zero))
}
}

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -11,7 +12,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -43,6 +44,7 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.TimedButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.send.ext.ABBREVIATION_INDEX
import co.electriccoin.zcash.ui.screen.send.ext.Saver
@ -63,10 +65,14 @@ fun PreviewSend() {
}
}
/**
* @param pressAndHoldInteractionSource This is an argument that can be injected for automated testing.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Send(
mySpendableBalance: Zatoshi,
pressAndHoldInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
goBack: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit
) {
@ -89,6 +95,7 @@ fun Send(
paddingValues,
mySpendableBalance,
sendStage,
pressAndHoldInteractionSource,
setSendStage,
onCreateAndSend = onCreateAndSend
)
@ -113,11 +120,13 @@ private fun SendTopAppBar(onBack: () -> Unit) {
)
}
@Suppress("LongParameterList")
@Composable
private fun SendMainContent(
paddingValues: PaddingValues,
myBalance: Zatoshi,
sendStage: SendStage,
pressAndHoldInteractionSource: MutableInteractionSource,
setSendStage: (SendStage) -> Unit,
onCreateAndSend: (ZecSend) -> Unit
) {
@ -135,7 +144,8 @@ private fun SendMainContent(
} else {
Confirmation(
paddingValues,
zecSend
zecSend,
pressAndHoldInteractionSource
) {
onCreateAndSend(zecSend)
}
@ -245,6 +255,7 @@ private fun SendForm(
private fun Confirmation(
paddingValues: PaddingValues,
zecSend: ZecSend,
pressAndHoldInteractionSource: MutableInteractionSource,
onConfirmation: () -> Unit
) {
Column(
@ -259,8 +270,10 @@ private fun Confirmation(
)
)
// TODO [#249]: Implement press-and-hold
Button(onClick = onConfirmation) {
TimedButton(
onClick = onConfirmation,
interactionSource = pressAndHoldInteractionSource
) {
Text(text = stringResource(id = R.string.send_confirm))
}
}