Pin unlock : implement design for in-app unlock

This commit is contained in:
ganfra 2023-10-31 20:12:45 +01:00
parent 8d903362c8
commit 564c2aa23e
6 changed files with 144 additions and 62 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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
) )
} }

View file

@ -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)

View file

@ -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,56 +84,7 @@ fun PinUnlockView(
} }
} }
Surface(modifier) { Surface(modifier) {
BoxWithConstraints { PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
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,
)
}
}
if (state.showSignOutPrompt) { if (state.showSignOutPrompt) {
SignOutPrompt( SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable, 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 @Composable
private fun SignOutPrompt( private fun SignOutPrompt(
isCancellable: Boolean, isCancellable: Boolean,
@ -248,16 +288,21 @@ 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) {
Icon( if (isInAppUnlock) {
modifier = Modifier RoundedIconAtom(imageVector = Icons.Filled.Lock)
.size(32.dp), } else {
tint = ElementTheme.colors.iconPrimary, Icon(
imageVector = Icons.Filled.Lock, modifier = Modifier
contentDescription = "", .size(32.dp),
) tint = ElementTheme.colors.iconPrimary,
imageVector = Icons.Filled.Lock,
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,
) )
Spacer(Modifier.height(24.dp)) if (!isInAppUnlock && state.pinEntry is Async.Success) {
if (state.pinEntry is Async.Success) { Spacer(Modifier.height(24.dp))
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,
) )
} }
} }