Pin unlock : implement design for in-app unlock

This commit is contained in:
ganfra 2023-10-31 20:12:45 +01:00
parent 6832b1f2db
commit 01f4175b79
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 {
return when (navTarget) {
NavTarget.Unlock -> {
createNode<PinUnlockNode>(buildContext)
val inputs = PinUnlockNode.Inputs(isInAppUnlock = false)
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
}
NavTarget.Setup -> {
createNode<LockScreenSetupFlowNode>(buildContext)

View file

@ -113,7 +113,8 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Unlock -> {
createNode<PinUnlockNode>(buildContext)
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
}
NavTarget.Setup -> {
val callback = object : LockScreenSetupFlowNode.Callback {

View file

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

View file

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

View file

@ -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<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 {
suspend {
matrixClient.logout(ignoreSdkError = true)

View file

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