Pin unlock : implement design for in-app unlock
This commit is contained in:
parent
8d903362c8
commit
564c2aa23e
6 changed files with 144 additions and 62 deletions
|
|
@ -94,7 +94,8 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Unlock -> {
|
NavTarget.Unlock -> {
|
||||||
createNode<PinUnlockNode>(buildContext)
|
val inputs = PinUnlockNode.Inputs(isInAppUnlock = false)
|
||||||
|
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
|
||||||
}
|
}
|
||||||
NavTarget.Setup -> {
|
NavTarget.Setup -> {
|
||||||
createNode<LockScreenSetupFlowNode>(buildContext)
|
createNode<LockScreenSetupFlowNode>(buildContext)
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,8 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Unlock -> {
|
NavTarget.Unlock -> {
|
||||||
createNode<PinUnlockNode>(buildContext)
|
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
|
||||||
|
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
|
||||||
}
|
}
|
||||||
NavTarget.Setup -> {
|
NavTarget.Setup -> {
|
||||||
val callback = object : LockScreenSetupFlowNode.Callback {
|
val callback = object : LockScreenSetupFlowNode.Callback {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||||
|
|
||||||
sealed interface PinUnlockEvents {
|
sealed interface PinUnlockEvents {
|
||||||
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
|
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
|
||||||
|
data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents
|
||||||
data object OnForgetPin : PinUnlockEvents
|
data object OnForgetPin : PinUnlockEvents
|
||||||
data object ClearSignOutPrompt : PinUnlockEvents
|
data object ClearSignOutPrompt : PinUnlockEvents
|
||||||
data object SignOut : PinUnlockEvents
|
data object SignOut : PinUnlockEvents
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
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
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -33,11 +35,18 @@ class PinUnlockNode @AssistedInject constructor(
|
||||||
private val presenter: PinUnlockPresenter,
|
private val presenter: PinUnlockPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val isInAppUnlock: Boolean
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
PinUnlockView(
|
PinUnlockView(
|
||||||
state = state,
|
state = state,
|
||||||
|
isInAppUnlock = inputs.isInAppUnlock,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,9 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
PinUnlockEvents.ClearBiometricError -> {
|
PinUnlockEvents.ClearBiometricError -> {
|
||||||
biometricUnlockResult = null
|
biometricUnlockResult = null
|
||||||
}
|
}
|
||||||
|
is PinUnlockEvents.OnPinEntryChanged -> {
|
||||||
|
pinEntryState.value = pinEntry.process(event.entryAsText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return PinUnlockState(
|
return PinUnlockState(
|
||||||
|
|
@ -159,6 +162,16 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Async<PinEntry>.process(pinEntryAsText: String): Async<PinEntry> {
|
||||||
|
return when (this) {
|
||||||
|
is Async.Success -> {
|
||||||
|
val pinEntry = data.fillWith(pinEntryAsText)
|
||||||
|
Async.Success(pinEntry)
|
||||||
|
}
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.signOut(signOutAction: MutableState<Async<String?>>) = launch {
|
private fun CoroutineScope.signOut(signOutAction: MutableState<Async<String?>>) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
matrixClient.logout(ignoreSdkError = true)
|
matrixClient.logout(ignoreSdkError = true)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.unlock
|
package io.element.android.features.lockscreen.impl.unlock
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
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.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import io.element.android.features.lockscreen.impl.R
|
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.PinDigit
|
||||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
|
||||||
import io.element.android.libraries.architecture.Async
|
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.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
|
@ -66,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun PinUnlockView(
|
fun PinUnlockView(
|
||||||
state: PinUnlockState,
|
state: PinUnlockState,
|
||||||
|
isInAppUnlock: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnLifecycleEvent { _, event ->
|
OnLifecycleEvent { _, event ->
|
||||||
|
|
@ -75,16 +84,44 @@ fun PinUnlockView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Surface(modifier) {
|
Surface(modifier) {
|
||||||
|
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
|
||||||
|
if (state.showSignOutPrompt) {
|
||||||
|
SignOutPrompt(
|
||||||
|
isCancellable = state.isSignOutPromptCancellable,
|
||||||
|
onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
|
||||||
|
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.signOutAction is Async.Loading) {
|
||||||
|
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
||||||
|
}
|
||||||
|
if (state.showBiometricUnlockError) {
|
||||||
|
ErrorDialog(
|
||||||
|
content = state.biometricUnlockErrorMessage ?: "",
|
||||||
|
onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinUnlockPage(
|
||||||
|
state: PinUnlockState,
|
||||||
|
isInAppUnlock: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
BoxWithConstraints {
|
BoxWithConstraints {
|
||||||
val commonModifier = Modifier
|
val commonModifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.systemBarsPadding()
|
.systemBarsPadding()
|
||||||
|
.imePadding()
|
||||||
.padding(all = 20.dp)
|
.padding(all = 20.dp)
|
||||||
|
|
||||||
val header = @Composable {
|
val header = @Composable {
|
||||||
PinUnlockHeader(
|
PinUnlockHeader(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)
|
isInAppUnlock = isInAppUnlock,
|
||||||
|
modifier = Modifier.padding(top = 60.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val footer = @Composable {
|
val footer = @Composable {
|
||||||
|
|
@ -100,6 +137,25 @@ fun PinUnlockView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val content = @Composable { constraints: BoxWithConstraintsScope ->
|
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(
|
PinKeypad(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
|
||||||
|
|
@ -109,6 +165,7 @@ fun PinUnlockView(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (maxHeight < 600.dp) {
|
if (maxHeight < 600.dp) {
|
||||||
PinUnlockCompactView(
|
PinUnlockCompactView(
|
||||||
header = header,
|
header = header,
|
||||||
|
|
@ -125,23 +182,6 @@ fun PinUnlockView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.showSignOutPrompt) {
|
|
||||||
SignOutPrompt(
|
|
||||||
isCancellable = state.isSignOutPromptCancellable,
|
|
||||||
onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
|
|
||||||
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.signOutAction is Async.Loading) {
|
|
||||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
|
||||||
}
|
|
||||||
if (state.showBiometricUnlockError) {
|
|
||||||
ErrorDialog(
|
|
||||||
content = state.biometricUnlockErrorMessage ?: "",
|
|
||||||
onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -248,9 +288,13 @@ private fun PinDot(
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinUnlockHeader(
|
private fun PinUnlockHeader(
|
||||||
state: PinUnlockState,
|
state: PinUnlockState,
|
||||||
|
isInAppUnlock: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
if (isInAppUnlock) {
|
||||||
|
RoundedIconAtom(imageVector = Icons.Filled.Lock)
|
||||||
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp),
|
.size(32.dp),
|
||||||
|
|
@ -258,6 +302,7 @@ private fun PinUnlockHeader(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = Icons.Filled.Lock,
|
||||||
contentDescription = "",
|
contentDescription = "",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = CommonStrings.common_enter_your_pin),
|
text = stringResource(id = CommonStrings.common_enter_your_pin),
|
||||||
|
|
@ -290,8 +335,8 @@ private fun PinUnlockHeader(
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
color = subtitleColor,
|
color = subtitleColor,
|
||||||
)
|
)
|
||||||
|
if (!isInAppUnlock && state.pinEntry is Async.Success) {
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
if (state.pinEntry is Async.Success) {
|
|
||||||
PinDotsRow(state.pinEntry.data)
|
PinDotsRow(state.pinEntry.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,10 +359,22 @@ private fun PinUnlockFooter(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
PinUnlockView(
|
PinUnlockView(
|
||||||
state = state,
|
state = state,
|
||||||
|
isInAppUnlock = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@PreviewsDayNight
|
||||||
|
internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||||
|
ElementPreview {
|
||||||
|
PinUnlockView(
|
||||||
|
state = state,
|
||||||
|
isInAppUnlock = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue