From 01f4175b79189faf95d16cd13901980988df8c85 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 31 Oct 2023 20:12:45 +0100 Subject: [PATCH] Pin unlock : implement design for in-app unlock --- .../lockscreen/impl/LockScreenFlowNode.kt | 3 +- .../settings/LockScreenSettingsFlowNode.kt | 3 +- .../lockscreen/impl/unlock/PinUnlockEvents.kt | 1 + .../lockscreen/impl/unlock/PinUnlockNode.kt | 9 + .../impl/unlock/PinUnlockPresenter.kt | 13 ++ .../lockscreen/impl/unlock/PinUnlockView.kt | 177 ++++++++++++------ 6 files changed, 144 insertions(+), 62 deletions(-) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt index bf729a5d84..48bfa465ee 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt @@ -94,7 +94,8 @@ class LockScreenFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Unlock -> { - createNode(buildContext) + val inputs = PinUnlockNode.Inputs(isInAppUnlock = false) + createNode(buildContext, plugins = listOf(inputs)) } NavTarget.Setup -> { createNode(buildContext) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index 2ccff9f7bb..82f5cafbe7 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -113,7 +113,8 @@ class LockScreenSettingsFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Unlock -> { - createNode(buildContext) + val inputs = PinUnlockNode.Inputs(isInAppUnlock = true) + createNode(buildContext, plugins = listOf(inputs)) } NavTarget.Setup -> { val callback = object : LockScreenSetupFlowNode.Callback { diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt index b2a33f1e24..4aded6f47c 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt @@ -20,6 +20,7 @@ import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel sealed interface PinUnlockEvents { data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents + data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents data object OnForgetPin : PinUnlockEvents data object ClearSignOutPrompt : PinUnlockEvents data object SignOut : PinUnlockEvents diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt index 88e076849e..a0818716b6 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -24,6 +24,8 @@ import com.bumble.appyx.core.plugin.Plugin import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -33,11 +35,18 @@ class PinUnlockNode @AssistedInject constructor( private val presenter: PinUnlockPresenter, ) : Node(buildContext, plugins = plugins) { + data class Inputs( + val isInAppUnlock: Boolean + ) : NodeInputs + + private val inputs: Inputs = inputs() + @Composable override fun View(modifier: Modifier) { val state = presenter.present() PinUnlockView( state = state, + isInAppUnlock = inputs.isInAppUnlock, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index 0e1f78d9e6..872547dfdc 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -116,6 +116,9 @@ class PinUnlockPresenter @Inject constructor( PinUnlockEvents.ClearBiometricError -> { biometricUnlockResult = null } + is PinUnlockEvents.OnPinEntryChanged -> { + pinEntryState.value = pinEntry.process(event.entryAsText) + } } } return PinUnlockState( @@ -159,6 +162,16 @@ class PinUnlockPresenter @Inject constructor( } } + private fun Async.process(pinEntryAsText: String): Async { + return when (this) { + is Async.Success -> { + val pinEntry = data.fillWith(pinEntryAsText) + Async.Success(pinEntry) + } + else -> this + } + } + private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { suspend { matrixClient.logout(ignoreSdkError = true) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt index 9d921eaaf8..fcca4985f5 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -14,6 +14,7 @@ * limitations under the License. */ + package io.element.android.features.lockscreen.impl.unlock import androidx.compose.foundation.background @@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding @@ -37,8 +39,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -46,10 +52,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import io.element.android.features.lockscreen.impl.R +import io.element.android.features.lockscreen.impl.components.PinEntryTextField import io.element.android.features.lockscreen.impl.pin.model.PinDigit import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog @@ -66,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PinUnlockView( state: PinUnlockState, + isInAppUnlock: Boolean, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -75,56 +84,7 @@ fun PinUnlockView( } } Surface(modifier) { - BoxWithConstraints { - val commonModifier = Modifier - .fillMaxSize() - .systemBarsPadding() - .padding(all = 20.dp) - - val header = @Composable { - PinUnlockHeader( - state = state, - modifier = Modifier.padding(top = 60.dp, bottom = 12.dp) - ) - } - val footer = @Composable { - PinUnlockFooter( - modifier = Modifier.padding(top = 24.dp), - showBiometricUnlock = state.showBiometricUnlock, - onUseBiometric = { - state.eventSink(PinUnlockEvents.OnUseBiometric) - }, - onForgotPin = { - state.eventSink(PinUnlockEvents.OnForgetPin) - }, - ) - } - val content = @Composable { constraints: BoxWithConstraintsScope -> - PinKeypad( - onClick = { - state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it)) - }, - maxWidth = constraints.maxWidth, - maxHeight = constraints.maxHeight, - horizontalAlignment = Alignment.CenterHorizontally, - ) - } - if (maxHeight < 600.dp) { - PinUnlockCompactView( - header = header, - footer = footer, - content = content, - modifier = commonModifier, - ) - } else { - PinUnlockExpandedView( - header = header, - footer = footer, - content = content, - modifier = commonModifier, - ) - } - } + PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock) if (state.showSignOutPrompt) { SignOutPrompt( isCancellable = state.isSignOutPromptCancellable, @@ -144,6 +104,86 @@ fun PinUnlockView( } } +@Composable +private fun PinUnlockPage( + state: PinUnlockState, + isInAppUnlock: Boolean, + modifier: Modifier = Modifier +) { + BoxWithConstraints { + val commonModifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding() + .padding(all = 20.dp) + + val header = @Composable { + PinUnlockHeader( + state = state, + isInAppUnlock = isInAppUnlock, + modifier = Modifier.padding(top = 60.dp) + ) + } + val footer = @Composable { + PinUnlockFooter( + modifier = Modifier.padding(top = 24.dp), + showBiometricUnlock = state.showBiometricUnlock, + onUseBiometric = { + state.eventSink(PinUnlockEvents.OnUseBiometric) + }, + onForgotPin = { + state.eventSink(PinUnlockEvents.OnForgetPin) + }, + ) + } + val content = @Composable { constraints: BoxWithConstraintsScope -> + if (isInAppUnlock) { + val pinEntry = state.pinEntry.dataOrNull() + if (pinEntry != null) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + PinEntryTextField( + pinEntry = pinEntry, + isSecured = true, + onValueChange = { + state.eventSink(PinUnlockEvents.OnPinEntryChanged(it)) + }, + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + ) + } + } else { + PinKeypad( + onClick = { + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it)) + }, + maxWidth = constraints.maxWidth, + maxHeight = constraints.maxHeight, + horizontalAlignment = Alignment.CenterHorizontally, + ) + } + } + if (maxHeight < 600.dp) { + PinUnlockCompactView( + header = header, + footer = footer, + content = content, + modifier = commonModifier, + ) + } else { + PinUnlockExpandedView( + header = header, + footer = footer, + content = content, + modifier = commonModifier, + ) + } + } +} + @Composable private fun SignOutPrompt( isCancellable: Boolean, @@ -248,16 +288,21 @@ private fun PinDot( @Composable private fun PinUnlockHeader( state: PinUnlockState, + isInAppUnlock: Boolean, modifier: Modifier = Modifier, ) { Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - modifier = Modifier - .size(32.dp), - tint = ElementTheme.colors.iconPrimary, - imageVector = Icons.Filled.Lock, - contentDescription = "", - ) + if (isInAppUnlock) { + RoundedIconAtom(imageVector = Icons.Filled.Lock) + } else { + Icon( + modifier = Modifier + .size(32.dp), + tint = ElementTheme.colors.iconPrimary, + imageVector = Icons.Filled.Lock, + contentDescription = "", + ) + } Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(id = CommonStrings.common_enter_your_pin), @@ -290,8 +335,8 @@ private fun PinUnlockHeader( style = ElementTheme.typography.fontBodyMdRegular, color = subtitleColor, ) - Spacer(Modifier.height(24.dp)) - if (state.pinEntry is Async.Success) { + if (!isInAppUnlock && state.pinEntry is Async.Success) { + Spacer(Modifier.height(24.dp)) PinDotsRow(state.pinEntry.data) } } @@ -314,10 +359,22 @@ private fun PinUnlockFooter( @Composable @PreviewsDayNight -internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { +internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { ElementPreview { PinUnlockView( state = state, + isInAppUnlock = true, + ) + } +} + +@Composable +@PreviewsDayNight +internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { + ElementPreview { + PinUnlockView( + state = state, + isInAppUnlock = false, ) } }