ganfra
11 months ago
committed by
GitHub
61 changed files with 1210 additions and 461 deletions
@ -0,0 +1,35 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.appconfig |
||||||
|
|
||||||
|
object LockScreenConfig { |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether the PIN is mandatory or not. |
||||||
|
*/ |
||||||
|
const val IS_PIN_MANDATORY: Boolean = false |
||||||
|
|
||||||
|
/** |
||||||
|
* Some PINs are blacklisted. |
||||||
|
*/ |
||||||
|
val PIN_BLACKLIST = setOf("0000", "1234") |
||||||
|
|
||||||
|
/** |
||||||
|
* The size of the PIN. |
||||||
|
*/ |
||||||
|
const val PIN_SIZE = 4 |
||||||
|
} |
@ -1,43 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright (c) 2023 New Vector Ltd |
|
||||||
* |
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|
||||||
* you may not use this file except in compliance with the License. |
|
||||||
* You may obtain a copy of the License at |
|
||||||
* |
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0 |
|
||||||
* |
|
||||||
* Unless required by applicable law or agreed to in writing, software |
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, |
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
||||||
* See the License for the specific language governing permissions and |
|
||||||
* limitations under the License. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.auth |
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable |
|
||||||
import io.element.android.features.lockscreen.api.LockScreenStateService |
|
||||||
import io.element.android.libraries.architecture.Presenter |
|
||||||
import kotlinx.coroutines.CoroutineScope |
|
||||||
import kotlinx.coroutines.launch |
|
||||||
import javax.inject.Inject |
|
||||||
|
|
||||||
class PinAuthenticationPresenter @Inject constructor( |
|
||||||
private val pinStateService: LockScreenStateService, |
|
||||||
private val coroutineScope: CoroutineScope, |
|
||||||
) : Presenter<PinAuthenticationState> { |
|
||||||
|
|
||||||
@Composable |
|
||||||
override fun present(): PinAuthenticationState { |
|
||||||
|
|
||||||
fun handleEvents(event: PinAuthenticationEvents) { |
|
||||||
when (event) { |
|
||||||
PinAuthenticationEvents.Unlock -> coroutineScope.launch { pinStateService.unlock() } |
|
||||||
} |
|
||||||
} |
|
||||||
return PinAuthenticationState( |
|
||||||
eventSink = ::handleEvents |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
@ -1,30 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright (c) 2023 New Vector Ltd |
|
||||||
* |
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|
||||||
* you may not use this file except in compliance with the License. |
|
||||||
* You may obtain a copy of the License at |
|
||||||
* |
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0 |
|
||||||
* |
|
||||||
* Unless required by applicable law or agreed to in writing, software |
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, |
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
||||||
* See the License for the specific language governing permissions and |
|
||||||
* limitations under the License. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.auth |
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
|
||||||
|
|
||||||
open class PinAuthenticationStateProvider : PreviewParameterProvider<PinAuthenticationState> { |
|
||||||
override val values: Sequence<PinAuthenticationState> |
|
||||||
get() = sequenceOf( |
|
||||||
aPinAuthenticationState(), |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
fun aPinAuthenticationState() = PinAuthenticationState( |
|
||||||
eventSink = {} |
|
||||||
) |
|
@ -1,84 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright (c) 2023 New Vector Ltd |
|
||||||
* |
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|
||||||
* you may not use this file except in compliance with the License. |
|
||||||
* You may obtain a copy of the License at |
|
||||||
* |
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0 |
|
||||||
* |
|
||||||
* Unless required by applicable law or agreed to in writing, software |
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, |
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
||||||
* See the License for the specific language governing permissions and |
|
||||||
* limitations under the License. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.auth |
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize |
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth |
|
||||||
import androidx.compose.foundation.layout.padding |
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding |
|
||||||
import androidx.compose.material.icons.Icons |
|
||||||
import androidx.compose.material.icons.filled.Lock |
|
||||||
import androidx.compose.runtime.Composable |
|
||||||
import androidx.compose.ui.Modifier |
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
|
||||||
import androidx.compose.ui.unit.dp |
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule |
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage |
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button |
|
||||||
import io.element.android.libraries.designsystem.theme.components.Surface |
|
||||||
|
|
||||||
@Composable |
|
||||||
fun PinAuthenticationView( |
|
||||||
state: PinAuthenticationState, |
|
||||||
modifier: Modifier = Modifier, |
|
||||||
) { |
|
||||||
Surface(modifier) { |
|
||||||
HeaderFooterPage( |
|
||||||
modifier = Modifier |
|
||||||
.systemBarsPadding() |
|
||||||
.fillMaxSize(), |
|
||||||
header = { PinAuthenticationHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) }, |
|
||||||
footer = { PinAuthenticationFooter(state) }, |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
private fun PinAuthenticationHeader( |
|
||||||
modifier: Modifier = Modifier, |
|
||||||
) { |
|
||||||
IconTitleSubtitleMolecule( |
|
||||||
modifier = modifier, |
|
||||||
title = "Element X is locked", |
|
||||||
subTitle = null, |
|
||||||
iconImageVector = Icons.Default.Lock, |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
private fun PinAuthenticationFooter(state: PinAuthenticationState) { |
|
||||||
Button( |
|
||||||
modifier = Modifier.fillMaxWidth(), |
|
||||||
text = "Unlock", |
|
||||||
onClick = { |
|
||||||
state.eventSink(PinAuthenticationEvents.Unlock) |
|
||||||
} |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@Composable |
|
||||||
@PreviewsDayNight |
|
||||||
internal fun PinAuthenticationViewPreview(@PreviewParameter(PinAuthenticationStateProvider::class) state: PinAuthenticationState) { |
|
||||||
ElementPreview { |
|
||||||
PinAuthenticationView( |
|
||||||
state = state, |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
@ -0,0 +1,117 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.components |
||||||
|
|
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.border |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||||
|
import androidx.compose.foundation.text.BasicTextField |
||||||
|
import androidx.compose.foundation.text.KeyboardOptions |
||||||
|
import androidx.compose.material3.Text |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.text.input.KeyboardType |
||||||
|
import androidx.compose.ui.text.input.TextFieldValue |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinDigit |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.theme.pinDigitBg |
||||||
|
import io.element.android.libraries.theme.ElementTheme |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun PinEntryTextField( |
||||||
|
pinEntry: PinEntry, |
||||||
|
onValueChange: (String) -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
BasicTextField( |
||||||
|
modifier = modifier, |
||||||
|
value = TextFieldValue(pinEntry.toText()), |
||||||
|
onValueChange = { |
||||||
|
onValueChange(it.text) |
||||||
|
}, |
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), |
||||||
|
decorationBox = { |
||||||
|
PinEntryRow(pinEntry = pinEntry) |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinEntryRow( |
||||||
|
pinEntry: PinEntry, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Row( |
||||||
|
modifier = modifier, |
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), |
||||||
|
verticalAlignment = Alignment.CenterVertically, |
||||||
|
) { |
||||||
|
for (digit in pinEntry.digits) { |
||||||
|
PinDigitView(digit = digit) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinDigitView( |
||||||
|
digit: PinDigit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
val shape = RoundedCornerShape(8.dp) |
||||||
|
val appearanceModifier = when (digit) { |
||||||
|
PinDigit.Empty -> { |
||||||
|
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) |
||||||
|
} |
||||||
|
is PinDigit.Filled -> { |
||||||
|
Modifier.background(ElementTheme.colors.pinDigitBg, shape) |
||||||
|
} |
||||||
|
} |
||||||
|
Box( |
||||||
|
modifier = modifier |
||||||
|
.size(48.dp) |
||||||
|
.then(appearanceModifier), |
||||||
|
contentAlignment = Alignment.Center, |
||||||
|
|
||||||
|
) { |
||||||
|
if (digit is PinDigit.Filled) { |
||||||
|
Text( |
||||||
|
text = digit.toText(), |
||||||
|
style = ElementTheme.typography.fontHeadingMdBold |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@PreviewsDayNight |
||||||
|
@Composable |
||||||
|
internal fun PinEntryTextFieldPreview() { |
||||||
|
ElementPreview { |
||||||
|
PinEntryTextField( |
||||||
|
pinEntry = PinEntry.createEmpty(4).fillWith("12"), |
||||||
|
onValueChange = {}, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -1,61 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright (c) 2023 New Vector Ltd |
|
||||||
* |
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|
||||||
* you may not use this file except in compliance with the License. |
|
||||||
* You may obtain a copy of the License at |
|
||||||
* |
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0 |
|
||||||
* |
|
||||||
* Unless required by applicable law or agreed to in writing, software |
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS, |
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
||||||
* See the License for the specific language governing permissions and |
|
||||||
* limitations under the License. |
|
||||||
*/ |
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.create |
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
|
||||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry |
|
||||||
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure |
|
||||||
|
|
||||||
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> { |
|
||||||
override val values: Sequence<CreatePinState> |
|
||||||
get() = sequenceOf( |
|
||||||
aCreatePinState(), |
|
||||||
aCreatePinState( |
|
||||||
choosePinEntry = PinEntry.empty(4).fillWith("12") |
|
||||||
), |
|
||||||
aCreatePinState( |
|
||||||
choosePinEntry = PinEntry.empty(4).fillWith("1789"), |
|
||||||
isConfirmationStep = true, |
|
||||||
), |
|
||||||
aCreatePinState( |
|
||||||
choosePinEntry = PinEntry.empty(4).fillWith("1789"), |
|
||||||
confirmPinEntry = PinEntry.empty(4).fillWith("1788"), |
|
||||||
isConfirmationStep = true, |
|
||||||
creationFailure = CreatePinFailure.PinsDontMatch |
|
||||||
), |
|
||||||
aCreatePinState( |
|
||||||
choosePinEntry = PinEntry.empty(4).fillWith("1111"), |
|
||||||
creationFailure = CreatePinFailure.PinBlacklisted |
|
||||||
), |
|
||||||
|
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
fun aCreatePinState( |
|
||||||
choosePinEntry: PinEntry = PinEntry.empty(4), |
|
||||||
confirmPinEntry: PinEntry = PinEntry.empty(4), |
|
||||||
isConfirmationStep: Boolean = false, |
|
||||||
creationFailure: CreatePinFailure? = null, |
|
||||||
) = CreatePinState( |
|
||||||
choosePinEntry = choosePinEntry, |
|
||||||
confirmPinEntry = confirmPinEntry, |
|
||||||
isConfirmationStep = isConfirmationStep, |
|
||||||
createPinFailure = creationFailure, |
|
||||||
appName = "Element", |
|
||||||
eventSink = {} |
|
||||||
) |
|
||||||
|
|
@ -0,0 +1,61 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.setup |
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry |
||||||
|
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure |
||||||
|
|
||||||
|
open class SetupPinStateProvider : PreviewParameterProvider<SetupPinState> { |
||||||
|
override val values: Sequence<SetupPinState> |
||||||
|
get() = sequenceOf( |
||||||
|
aSetupPinState(), |
||||||
|
aSetupPinState( |
||||||
|
choosePinEntry = PinEntry.createEmpty(4).fillWith("12") |
||||||
|
), |
||||||
|
aSetupPinState( |
||||||
|
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"), |
||||||
|
isConfirmationStep = true, |
||||||
|
), |
||||||
|
aSetupPinState( |
||||||
|
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"), |
||||||
|
confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"), |
||||||
|
isConfirmationStep = true, |
||||||
|
creationFailure = SetupPinFailure.PinsDontMatch |
||||||
|
), |
||||||
|
aSetupPinState( |
||||||
|
choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"), |
||||||
|
creationFailure = SetupPinFailure.PinBlacklisted |
||||||
|
), |
||||||
|
|
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
fun aSetupPinState( |
||||||
|
choosePinEntry: PinEntry = PinEntry.createEmpty(4), |
||||||
|
confirmPinEntry: PinEntry = PinEntry.createEmpty(4), |
||||||
|
isConfirmationStep: Boolean = false, |
||||||
|
creationFailure: SetupPinFailure? = null, |
||||||
|
) = SetupPinState( |
||||||
|
choosePinEntry = choosePinEntry, |
||||||
|
confirmPinEntry = confirmPinEntry, |
||||||
|
isConfirmationStep = isConfirmationStep, |
||||||
|
setupPinFailure = creationFailure, |
||||||
|
appName = "Element", |
||||||
|
eventSink = {} |
||||||
|
) |
||||||
|
|
@ -0,0 +1,25 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel |
||||||
|
|
||||||
|
sealed interface PinUnlockEvents { |
||||||
|
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents |
||||||
|
data object OnForgetPin : PinUnlockEvents |
||||||
|
data object ClearSignOutPrompt : PinUnlockEvents |
||||||
|
} |
@ -0,0 +1,86 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableIntStateOf |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import io.element.android.appconfig.LockScreenConfig |
||||||
|
import io.element.android.features.lockscreen.api.LockScreenStateService |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry |
||||||
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel |
||||||
|
import io.element.android.libraries.architecture.Presenter |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
import javax.inject.Inject |
||||||
|
|
||||||
|
class PinUnlockPresenter @Inject constructor( |
||||||
|
private val pinStateService: LockScreenStateService, |
||||||
|
private val coroutineScope: CoroutineScope, |
||||||
|
) : Presenter<PinUnlockState> { |
||||||
|
|
||||||
|
@Composable |
||||||
|
override fun present(): PinUnlockState { |
||||||
|
var pinEntry by remember { |
||||||
|
//TODO fetch size from db |
||||||
|
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE)) |
||||||
|
} |
||||||
|
var remainingAttempts by rememberSaveable { |
||||||
|
//TODO fetch from db |
||||||
|
mutableIntStateOf(3) |
||||||
|
} |
||||||
|
var showWrongPinTitle by rememberSaveable { |
||||||
|
mutableStateOf(false) |
||||||
|
} |
||||||
|
var showSignOutPrompt by rememberSaveable { |
||||||
|
mutableStateOf(false) |
||||||
|
} |
||||||
|
|
||||||
|
fun handleEvents(event: PinUnlockEvents) { |
||||||
|
when (event) { |
||||||
|
is PinUnlockEvents.OnPinKeypadPressed -> { |
||||||
|
pinEntry = pinEntry.process(event.pinKeypadModel) |
||||||
|
if (pinEntry.isComplete()) { |
||||||
|
//TODO check pin with PinCodeManager |
||||||
|
coroutineScope.launch { pinStateService.unlock() } |
||||||
|
} |
||||||
|
} |
||||||
|
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true |
||||||
|
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false |
||||||
|
} |
||||||
|
} |
||||||
|
return PinUnlockState( |
||||||
|
pinEntry = pinEntry, |
||||||
|
showWrongPinTitle = showWrongPinTitle, |
||||||
|
remainingAttempts = remainingAttempts, |
||||||
|
showSignOutPrompt = showSignOutPrompt, |
||||||
|
eventSink = ::handleEvents |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
private fun PinEntry.process(pinKeypadModel: PinKeypadModel): PinEntry { |
||||||
|
return when (pinKeypadModel) { |
||||||
|
PinKeypadModel.Back -> deleteLast() |
||||||
|
is PinKeypadModel.Number -> addDigit(pinKeypadModel.number) |
||||||
|
PinKeypadModel.Empty -> this |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,29 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry |
||||||
|
|
||||||
|
data class PinUnlockState( |
||||||
|
val pinEntry: PinEntry, |
||||||
|
val showWrongPinTitle: Boolean, |
||||||
|
val remainingAttempts: Int, |
||||||
|
val showSignOutPrompt: Boolean, |
||||||
|
val eventSink: (PinUnlockEvents) -> Unit |
||||||
|
) { |
||||||
|
val isSignOutPromptCancellable = remainingAttempts > 0 |
||||||
|
} |
@ -0,0 +1,44 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.PinEntry |
||||||
|
|
||||||
|
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> { |
||||||
|
override val values: Sequence<PinUnlockState> |
||||||
|
get() = sequenceOf( |
||||||
|
aPinUnlockState(), |
||||||
|
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")), |
||||||
|
aPinUnlockState(showWrongPinTitle = true), |
||||||
|
aPinUnlockState(showSignOutPrompt = true), |
||||||
|
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
fun aPinUnlockState( |
||||||
|
pinEntry: PinEntry = PinEntry.createEmpty(4), |
||||||
|
remainingAttempts: Int = 3, |
||||||
|
showWrongPinTitle: Boolean = false, |
||||||
|
showSignOutPrompt: Boolean = false, |
||||||
|
) = PinUnlockState( |
||||||
|
pinEntry = pinEntry, |
||||||
|
showWrongPinTitle = showWrongPinTitle, |
||||||
|
remainingAttempts = remainingAttempts, |
||||||
|
showSignOutPrompt = showSignOutPrompt, |
||||||
|
eventSink = {} |
||||||
|
) |
@ -0,0 +1,270 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Arrangement.spacedBy |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints |
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraintsScope |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
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.padding |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding |
||||||
|
import androidx.compose.foundation.shape.CircleShape |
||||||
|
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.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.res.pluralStringResource |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.text.style.TextAlign |
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.features.lockscreen.impl.R |
||||||
|
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.designsystem.components.dialogs.ConfirmationDialog |
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton |
||||||
|
import io.element.android.libraries.theme.ElementTheme |
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun PinUnlockView( |
||||||
|
state: PinUnlockState, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
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) |
||||||
|
) |
||||||
|
} |
||||||
|
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.isSignOutPromptCancellable) { |
||||||
|
ConfirmationDialog( |
||||||
|
title = stringResource(id = R.string.screen_app_lock_signout_alert_title), |
||||||
|
content = stringResource(id = R.string.screen_app_lock_signout_alert_message), |
||||||
|
onSubmitClicked = {}, |
||||||
|
onDismiss = {}, |
||||||
|
) |
||||||
|
} else { |
||||||
|
ErrorDialog( |
||||||
|
title = stringResource(id = R.string.screen_app_lock_signout_alert_title), |
||||||
|
content = stringResource(id = R.string.screen_app_lock_signout_alert_message), |
||||||
|
onDismiss = {}, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinUnlockCompactView( |
||||||
|
header: @Composable () -> Unit, |
||||||
|
footer: @Composable () -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
content: @Composable BoxWithConstraintsScope.() -> Unit, |
||||||
|
) { |
||||||
|
Row(modifier = modifier) { |
||||||
|
Column(Modifier.weight(1f)) { |
||||||
|
header() |
||||||
|
Spacer(modifier = Modifier.height(24.dp)) |
||||||
|
footer() |
||||||
|
} |
||||||
|
BoxWithConstraints( |
||||||
|
modifier = Modifier |
||||||
|
.weight(1f) |
||||||
|
.fillMaxHeight(), |
||||||
|
contentAlignment = Alignment.Center, |
||||||
|
) { |
||||||
|
content() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinUnlockExpandedView( |
||||||
|
header: @Composable () -> Unit, |
||||||
|
footer: @Composable () -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
content: @Composable BoxWithConstraintsScope.() -> Unit, |
||||||
|
) { |
||||||
|
Column( |
||||||
|
modifier = modifier, |
||||||
|
) { |
||||||
|
header() |
||||||
|
BoxWithConstraints( |
||||||
|
modifier = Modifier |
||||||
|
.weight(1f) |
||||||
|
.fillMaxWidth() |
||||||
|
.padding(top = 40.dp), |
||||||
|
) { |
||||||
|
content() |
||||||
|
} |
||||||
|
footer() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinDotsRow( |
||||||
|
pinEntry: PinEntry, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Row(modifier, horizontalArrangement = spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { |
||||||
|
for (digit in pinEntry.digits) { |
||||||
|
PinDot(isFilled = digit is PinDigit.Filled) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinDot( |
||||||
|
isFilled: Boolean, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
val backgroundColor = if (isFilled) { |
||||||
|
ElementTheme.colors.iconPrimary |
||||||
|
} else { |
||||||
|
ElementTheme.colors.bgSubtlePrimary |
||||||
|
} |
||||||
|
Box( |
||||||
|
modifier = modifier |
||||||
|
.size(14.dp) |
||||||
|
.background(backgroundColor, CircleShape) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinUnlockHeader( |
||||||
|
state: PinUnlockState, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { |
||||||
|
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), |
||||||
|
modifier = Modifier |
||||||
|
.fillMaxWidth(), |
||||||
|
textAlign = TextAlign.Center, |
||||||
|
style = ElementTheme.typography.fontHeadingMdBold, |
||||||
|
color = MaterialTheme.colorScheme.primary, |
||||||
|
) |
||||||
|
Spacer(Modifier.height(8.dp)) |
||||||
|
val subtitle = if (state.showWrongPinTitle) { |
||||||
|
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = state.remainingAttempts, state.remainingAttempts) |
||||||
|
} else { |
||||||
|
stringResource(id = R.string.screen_app_lock_subtitle) |
||||||
|
} |
||||||
|
val subtitleColor = if (state.showWrongPinTitle) { |
||||||
|
MaterialTheme.colorScheme.error |
||||||
|
} else { |
||||||
|
MaterialTheme.colorScheme.secondary |
||||||
|
} |
||||||
|
Text( |
||||||
|
text = subtitle, |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
textAlign = TextAlign.Center, |
||||||
|
style = ElementTheme.typography.fontBodyMdRegular, |
||||||
|
color = subtitleColor, |
||||||
|
) |
||||||
|
Spacer(Modifier.height(24.dp)) |
||||||
|
PinDotsRow(state.pinEntry) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinUnlockFooter( |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { |
||||||
|
TextButton(text = "Use biometric", onClick = { }) |
||||||
|
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = { }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
@PreviewsDayNight |
||||||
|
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { |
||||||
|
ElementPreview { |
||||||
|
PinUnlockView( |
||||||
|
state = state, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
@ -0,0 +1,214 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock.keypad |
||||||
|
|
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Arrangement.spacedBy |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.BoxScope |
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.shape.CircleShape |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.automirrored.filled.Backspace |
||||||
|
import androidx.compose.material3.Text |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.draw.clip |
||||||
|
import androidx.compose.ui.unit.Dp |
||||||
|
import androidx.compose.ui.unit.coerceAtMost |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.unit.sp |
||||||
|
import androidx.compose.ui.unit.times |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||||
|
import io.element.android.libraries.designsystem.text.toSp |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon |
||||||
|
import io.element.android.libraries.theme.ElementTheme |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
import kotlinx.collections.immutable.persistentListOf |
||||||
|
|
||||||
|
private val spaceBetweenPinKey = 16.dp |
||||||
|
private val maxSizePinKey = 80.dp |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun PinKeypad( |
||||||
|
onClick: (PinKeypadModel) -> Unit, |
||||||
|
maxWidth: Dp, |
||||||
|
maxHeight: Dp, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
verticalAlignment: Alignment.Vertical = Alignment.Top, |
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.Start, |
||||||
|
) { |
||||||
|
val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceAtMost(maxSizePinKey) |
||||||
|
val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceAtMost(maxSizePinKey) |
||||||
|
val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight |
||||||
|
|
||||||
|
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally) |
||||||
|
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically) |
||||||
|
Column( |
||||||
|
modifier = modifier, |
||||||
|
verticalArrangement = verticalArrangement, |
||||||
|
horizontalAlignment = horizontalAlignment, |
||||||
|
) { |
||||||
|
PinKeypadRow( |
||||||
|
pinKeySize = pinKeySize, |
||||||
|
verticalAlignment = verticalAlignment, |
||||||
|
horizontalArrangement = horizontalArrangement, |
||||||
|
models = persistentListOf(PinKeypadModel.Number('1'), PinKeypadModel.Number('2'), PinKeypadModel.Number('3')), |
||||||
|
onClick = onClick, |
||||||
|
) |
||||||
|
PinKeypadRow( |
||||||
|
pinKeySize = pinKeySize, |
||||||
|
verticalAlignment = verticalAlignment, |
||||||
|
horizontalArrangement = horizontalArrangement, |
||||||
|
models = persistentListOf(PinKeypadModel.Number('4'), PinKeypadModel.Number('5'), PinKeypadModel.Number('6')), |
||||||
|
onClick = onClick, |
||||||
|
) |
||||||
|
PinKeypadRow( |
||||||
|
pinKeySize = pinKeySize, |
||||||
|
verticalAlignment = verticalAlignment, |
||||||
|
horizontalArrangement = horizontalArrangement, |
||||||
|
models = persistentListOf(PinKeypadModel.Number('7'), PinKeypadModel.Number('8'), PinKeypadModel.Number('9')), |
||||||
|
onClick = onClick, |
||||||
|
) |
||||||
|
PinKeypadRow( |
||||||
|
pinKeySize = pinKeySize, |
||||||
|
verticalAlignment = verticalAlignment, |
||||||
|
horizontalArrangement = horizontalArrangement, |
||||||
|
models = persistentListOf(PinKeypadModel.Empty, PinKeypadModel.Number('0'), PinKeypadModel.Back), |
||||||
|
onClick = onClick, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinKeypadRow( |
||||||
|
models: ImmutableList<PinKeypadModel>, |
||||||
|
onClick: (PinKeypadModel) -> Unit, |
||||||
|
pinKeySize: Dp, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, |
||||||
|
verticalAlignment: Alignment.Vertical = Alignment.Top, |
||||||
|
) { |
||||||
|
Row( |
||||||
|
horizontalArrangement = horizontalArrangement, |
||||||
|
verticalAlignment = verticalAlignment, |
||||||
|
modifier = modifier.fillMaxWidth(), |
||||||
|
) { |
||||||
|
val commonModifier = Modifier.size(pinKeySize) |
||||||
|
for (model in models) { |
||||||
|
when (model) { |
||||||
|
is PinKeypadModel.Empty -> { |
||||||
|
Spacer(modifier = commonModifier) |
||||||
|
} |
||||||
|
is PinKeypadModel.Back -> { |
||||||
|
PinKeypadBackButton( |
||||||
|
modifier = commonModifier, |
||||||
|
onClick = { onClick(model) }, |
||||||
|
) |
||||||
|
} |
||||||
|
is PinKeypadModel.Number -> { |
||||||
|
PinKeyBadDigitButton( |
||||||
|
size = pinKeySize, |
||||||
|
modifier = commonModifier, |
||||||
|
digit = model.number.toString(), |
||||||
|
onClick = { onClick(model) }, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinKeypadButton( |
||||||
|
onClick: () -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
content: @Composable BoxScope.() -> Unit, |
||||||
|
) { |
||||||
|
Box( |
||||||
|
contentAlignment = Alignment.Center, |
||||||
|
modifier = modifier |
||||||
|
.clip(CircleShape) |
||||||
|
.background(color = ElementTheme.colors.bgSubtlePrimary) |
||||||
|
.clickable(onClick = onClick), |
||||||
|
content = content |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinKeyBadDigitButton( |
||||||
|
digit: String, |
||||||
|
size: Dp, |
||||||
|
onClick: (String) -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
PinKeypadButton( |
||||||
|
modifier = modifier, |
||||||
|
onClick = { onClick(digit) } |
||||||
|
) { |
||||||
|
val fontSize = size.toSp() / 2 |
||||||
|
val originalFont = ElementTheme.typography.fontHeadingXlBold |
||||||
|
val ratio = fontSize.value / originalFont.fontSize.value |
||||||
|
val lineHeight = originalFont.lineHeight * ratio |
||||||
|
Text( |
||||||
|
text = digit, |
||||||
|
color = ElementTheme.colors.textPrimary, |
||||||
|
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp), |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun PinKeypadBackButton( |
||||||
|
onClick: () -> Unit, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
) { |
||||||
|
PinKeypadButton( |
||||||
|
modifier = modifier, |
||||||
|
onClick = onClick, |
||||||
|
) { |
||||||
|
Icon( |
||||||
|
imageVector = Icons.AutoMirrored.Filled.Backspace, |
||||||
|
contentDescription = null, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
@PreviewsDayNight |
||||||
|
internal fun PinKeypadPreview() { |
||||||
|
ElementPreview { |
||||||
|
BoxWithConstraints { |
||||||
|
PinKeypad( |
||||||
|
maxWidth = maxWidth, |
||||||
|
maxHeight = maxHeight, |
||||||
|
onClick = {} |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
@ -0,0 +1,89 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.lockscreen.impl.unlock |
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode |
||||||
|
import app.cash.molecule.moleculeFlow |
||||||
|
import app.cash.turbine.test |
||||||
|
import com.google.common.truth.Truth.assertThat |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty |
||||||
|
import io.element.android.features.lockscreen.impl.pin.model.assertText |
||||||
|
import io.element.android.features.lockscreen.impl.state.DefaultLockScreenStateService |
||||||
|
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel |
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags |
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService |
||||||
|
import io.element.android.tests.testutils.awaitLastSequentialItem |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.test.runTest |
||||||
|
import org.junit.Test |
||||||
|
|
||||||
|
class PinUnlockPresenterTest { |
||||||
|
|
||||||
|
private val halfCompletePin = "12" |
||||||
|
private val completePin = "1235" |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `present - complete flow`() = runTest { |
||||||
|
val presenter = createPinUnlockPresenter(this) |
||||||
|
moleculeFlow(RecompositionMode.Immediate) { |
||||||
|
presenter.present() |
||||||
|
}.test { |
||||||
|
awaitItem().also { state -> |
||||||
|
state.pinEntry.assertEmpty() |
||||||
|
assertThat(state.showWrongPinTitle).isFalse() |
||||||
|
assertThat(state.showSignOutPrompt).isFalse() |
||||||
|
assertThat(state.remainingAttempts).isEqualTo(3) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2'))) |
||||||
|
} |
||||||
|
awaitLastSequentialItem().also { state -> |
||||||
|
state.pinEntry.assertText(halfCompletePin) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back)) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty)) |
||||||
|
} |
||||||
|
awaitLastSequentialItem().also { state -> |
||||||
|
state.pinEntry.assertText(halfCompletePin) |
||||||
|
state.eventSink(PinUnlockEvents.OnForgetPin) |
||||||
|
} |
||||||
|
awaitLastSequentialItem().also { state -> |
||||||
|
assertThat(state.showSignOutPrompt).isEqualTo(true) |
||||||
|
assertThat(state.isSignOutPromptCancellable).isEqualTo(true) |
||||||
|
state.eventSink(PinUnlockEvents.ClearSignOutPrompt) |
||||||
|
} |
||||||
|
awaitLastSequentialItem().also { state -> |
||||||
|
assertThat(state.showSignOutPrompt).isEqualTo(false) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) |
||||||
|
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5'))) |
||||||
|
} |
||||||
|
awaitLastSequentialItem().also { state -> |
||||||
|
state.pinEntry.assertText(completePin) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private suspend fun createPinUnlockPresenter(scope: CoroutineScope): PinUnlockPresenter { |
||||||
|
val featureFlagService = FakeFeatureFlagService().apply { |
||||||
|
setFeatureEnabled(FeatureFlags.PinUnlock, true) |
||||||
|
} |
||||||
|
val lockScreenStateService = DefaultLockScreenStateService(featureFlagService) |
||||||
|
return PinUnlockPresenter( |
||||||
|
lockScreenStateService, |
||||||
|
scope, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue