Merge pull request #1624 from vector-im/feature/fga/pin_auth_ui
PIN : unlock screen ui
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -250,9 +250,6 @@ koverMerged {
|
||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||
// Some options can't be tested at the moment
|
||||
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
|
||||
// Temporary until we have actually something to test.
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter"
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
|
||||
}
|
||||
bound {
|
||||
minValue = 85
|
||||
|
||||
@@ -30,9 +30,11 @@ anvil {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
ksp(libs.showkase.processor)
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.lockscreen.api)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
@@ -40,6 +42,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.cryptography.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
@@ -50,6 +53,5 @@ dependencies {
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.cryptography.test)
|
||||
testImplementation(projects.libraries.cryptography.impl)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.lockscreen.impl.auth.PinAuthenticationNode
|
||||
import io.element.android.features.lockscreen.impl.create.CreatePinNode
|
||||
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -41,7 +41,7 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<LockScreenFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Auth,
|
||||
initialElement = NavTarget.Unlock,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
@@ -50,19 +50,19 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Auth : NavTarget
|
||||
data object Unlock : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Create : NavTarget
|
||||
data object Setup : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Auth -> {
|
||||
createNode<PinAuthenticationNode>(buildContext)
|
||||
NavTarget.Unlock -> {
|
||||
createNode<PinUnlockNode>(buildContext)
|
||||
}
|
||||
NavTarget.Create -> {
|
||||
createNode<CreatePinNode>(buildContext)
|
||||
NavTarget.Setup -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create.model
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
sealed interface PinDigit {
|
||||
data object Empty : PinDigit
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create.model
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
@@ -24,7 +24,7 @@ data class PinEntry(
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun empty(size: Int): PinEntry {
|
||||
fun createEmpty(size: Int): PinEntry {
|
||||
val digits = List(size) { PinDigit.Empty }
|
||||
return PinEntry(
|
||||
digits = digits.toPersistentList()
|
||||
@@ -50,14 +50,36 @@ data class PinEntry(
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun clear(): PinEntry {
|
||||
return fillWith("")
|
||||
fun deleteLast(): PinEntry {
|
||||
if (isEmpty()) return this
|
||||
val newDigits = digits.toMutableList()
|
||||
newDigits.indexOfLast { it is PinDigit.Filled }.also { lastFilled ->
|
||||
newDigits[lastFilled] = PinDigit.Empty
|
||||
}
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun isPinComplete(): Boolean {
|
||||
fun addDigit(digit: Char): PinEntry {
|
||||
if (isComplete()) return this
|
||||
val newDigits = digits.toMutableList()
|
||||
newDigits.indexOfFirst { it is PinDigit.Empty }.also { firstEmpty ->
|
||||
newDigits[firstEmpty] = PinDigit.Filled(digit)
|
||||
}
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun clear(): PinEntry {
|
||||
return createEmpty(size)
|
||||
}
|
||||
|
||||
fun isComplete(): Boolean {
|
||||
return digits.all { it is PinDigit.Filled }
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return digits.all { it is PinDigit.Empty }
|
||||
}
|
||||
|
||||
fun toText(): String {
|
||||
return digits.joinToString("") {
|
||||
it.toText()
|
||||
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create.validation
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
sealed interface CreatePinFailure {
|
||||
data object PinBlacklisted : CreatePinFailure
|
||||
data object PinsDontMatch : CreatePinFailure
|
||||
sealed interface SetupPinEvents {
|
||||
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
|
||||
data object ClearFailure : SetupPinEvents
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,16 +27,16 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class CreatePinNode @AssistedInject constructor(
|
||||
class SetupPinNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: CreatePinPresenter,
|
||||
private val presenter: SetupPinPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
CreatePinView(
|
||||
SetupPinView(
|
||||
state = state,
|
||||
onBackClicked = { },
|
||||
modifier = modifier
|
||||
@@ -14,88 +14,87 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PIN_SIZE = 4
|
||||
|
||||
class CreatePinPresenter @Inject constructor(
|
||||
class SetupPinPresenter @Inject constructor(
|
||||
private val pinValidator: PinValidator,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<CreatePinState> {
|
||||
) : Presenter<SetupPinState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): CreatePinState {
|
||||
override fun present(): SetupPinState {
|
||||
var choosePinEntry by remember {
|
||||
mutableStateOf(PinEntry.empty(PIN_SIZE))
|
||||
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
|
||||
}
|
||||
var confirmPinEntry by remember {
|
||||
mutableStateOf(PinEntry.empty(PIN_SIZE))
|
||||
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
|
||||
}
|
||||
var isConfirmationStep by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var createPinFailure by remember {
|
||||
mutableStateOf<CreatePinFailure?>(null)
|
||||
var setupPinFailure by remember {
|
||||
mutableStateOf<SetupPinFailure?>(null)
|
||||
}
|
||||
|
||||
fun handleEvents(event: CreatePinEvents) {
|
||||
fun handleEvents(event: SetupPinEvents) {
|
||||
when (event) {
|
||||
is CreatePinEvents.OnPinEntryChanged -> {
|
||||
is SetupPinEvents.OnPinEntryChanged -> {
|
||||
if (isConfirmationStep) {
|
||||
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||
if (confirmPinEntry.isPinComplete()) {
|
||||
if (confirmPinEntry.isComplete()) {
|
||||
if (confirmPinEntry == choosePinEntry) {
|
||||
//TODO save in db and navigate to next screen
|
||||
} else {
|
||||
createPinFailure = CreatePinFailure.PinsDontMatch
|
||||
setupPinFailure = SetupPinFailure.PinsDontMatch
|
||||
}
|
||||
}
|
||||
} else {
|
||||
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||
if (choosePinEntry.isPinComplete()) {
|
||||
if (choosePinEntry.isComplete()) {
|
||||
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
|
||||
is PinValidator.Result.Invalid -> {
|
||||
createPinFailure = pinValidationResult.failure
|
||||
setupPinFailure = pinValidationResult.failure
|
||||
}
|
||||
PinValidator.Result.Valid -> isConfirmationStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CreatePinEvents.ClearFailure -> {
|
||||
when (createPinFailure) {
|
||||
is CreatePinFailure.PinsDontMatch -> {
|
||||
choosePinEntry = PinEntry.empty(PIN_SIZE)
|
||||
confirmPinEntry = PinEntry.empty(PIN_SIZE)
|
||||
SetupPinEvents.ClearFailure -> {
|
||||
when (setupPinFailure) {
|
||||
is SetupPinFailure.PinsDontMatch -> {
|
||||
choosePinEntry = choosePinEntry.clear()
|
||||
confirmPinEntry = confirmPinEntry.clear()
|
||||
}
|
||||
is CreatePinFailure.PinBlacklisted -> {
|
||||
choosePinEntry = PinEntry.empty(PIN_SIZE)
|
||||
is SetupPinFailure.PinBlacklisted -> {
|
||||
choosePinEntry = choosePinEntry.clear()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
isConfirmationStep = false
|
||||
createPinFailure = null
|
||||
setupPinFailure = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CreatePinState(
|
||||
return SetupPinState(
|
||||
choosePinEntry = choosePinEntry,
|
||||
confirmPinEntry = confirmPinEntry,
|
||||
isConfirmationStep = isConfirmationStep,
|
||||
createPinFailure = createPinFailure,
|
||||
setupPinFailure = setupPinFailure,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
@@ -14,20 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
|
||||
data class CreatePinState(
|
||||
data class SetupPinState(
|
||||
val choosePinEntry: PinEntry,
|
||||
val confirmPinEntry: PinEntry,
|
||||
val isConfirmationStep: Boolean,
|
||||
val createPinFailure: CreatePinFailure?,
|
||||
val setupPinFailure: SetupPinFailure?,
|
||||
val appName: String,
|
||||
val eventSink: (CreatePinEvents) -> Unit
|
||||
val eventSink: (SetupPinEvents) -> Unit
|
||||
) {
|
||||
val pinSize = choosePinEntry.size
|
||||
val activePinEntry = if (isConfirmationStep) {
|
||||
confirmPinEntry
|
||||
} else {
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -16,23 +16,14 @@
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
@@ -41,28 +32,22 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
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.create.model.PinDigit
|
||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||
import io.element.android.features.lockscreen.impl.components.PinEntryTextField
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
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.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.pinDigitBg
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun CreatePinView(
|
||||
state: CreatePinState,
|
||||
fun SetupPinView(
|
||||
state: SetupPinState,
|
||||
onBackClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -86,15 +71,15 @@ fun CreatePinView(
|
||||
.verticalScroll(state = scrollState)
|
||||
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||
) {
|
||||
CreatePinHeader(state.isConfirmationStep, state.appName)
|
||||
CreatePinContent(state)
|
||||
SetupPinHeader(state.isConfirmationStep, state.appName)
|
||||
SetupPinContent(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePinHeader(
|
||||
private fun SetupPinHeader(
|
||||
isValidationStep: Boolean,
|
||||
appName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -116,118 +101,52 @@ private fun CreatePinHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePinContent(
|
||||
state: CreatePinState,
|
||||
private fun SetupPinContent(
|
||||
state: SetupPinState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PinEntryTextField(
|
||||
state.activePinEntry,
|
||||
onValueChange = {
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(it))
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
|
||||
},
|
||||
modifier = modifier
|
||||
.padding(top = 36.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
if (state.createPinFailure != null) {
|
||||
if (state.setupPinFailure != null) {
|
||||
ErrorDialog(
|
||||
modifier = modifier,
|
||||
title = state.createPinFailure.title(),
|
||||
content = state.createPinFailure.content(),
|
||||
title = state.setupPinFailure.title(),
|
||||
content = state.setupPinFailure.content(),
|
||||
onDismiss = {
|
||||
state.eventSink(CreatePinEvents.ClearFailure)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePinFailure.content(): String {
|
||||
private fun SetupPinFailure.content(): String {
|
||||
return when (this) {
|
||||
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
|
||||
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
|
||||
SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
|
||||
SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePinFailure.title(): String {
|
||||
private fun SetupPinFailure.title(): String {
|
||||
return when (this) {
|
||||
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
|
||||
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private 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
|
||||
)
|
||||
}
|
||||
|
||||
SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
|
||||
SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
|
||||
internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) {
|
||||
ElementPreview {
|
||||
CreatePinView(
|
||||
SetupPinView(
|
||||
state = state,
|
||||
onBackClicked = {},
|
||||
)
|
||||
@@ -14,29 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create.validation
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class PinValidator @Inject constructor() {
|
||||
class PinValidator internal constructor(private val pinBlacklist: Set<String>) {
|
||||
|
||||
companion object {
|
||||
@VisibleForTesting
|
||||
val BLACKLIST = listOf("0000", "1234")
|
||||
}
|
||||
@Inject
|
||||
constructor() : this(LockScreenConfig.PIN_BLACKLIST)
|
||||
|
||||
sealed interface Result {
|
||||
data object Valid : Result
|
||||
data class Invalid(val failure: CreatePinFailure) : Result
|
||||
data class Invalid(val failure: SetupPinFailure) : Result
|
||||
}
|
||||
|
||||
fun isPinValid(pinEntry: PinEntry): Result {
|
||||
val pinAsText = pinEntry.toText()
|
||||
val isBlacklisted = BLACKLIST.any { it == pinAsText }
|
||||
val isBlacklisted = pinBlacklist.any { it == pinAsText }
|
||||
return if (isBlacklisted) {
|
||||
Result.Invalid(CreatePinFailure.PinBlacklisted)
|
||||
Result.Invalid(SetupPinFailure.PinBlacklisted)
|
||||
} else {
|
||||
Result.Valid
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
|
||||
sealed interface PinAuthenticationEvents {
|
||||
data object Unlock : PinAuthenticationEvents
|
||||
sealed interface SetupPinFailure {
|
||||
data object PinBlacklisted : SetupPinFailure
|
||||
data object PinsDontMatch : SetupPinFailure
|
||||
}
|
||||
@@ -25,13 +25,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
|
||||
//private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
@@ -57,7 +56,7 @@ class DefaultLockScreenStateService @Inject constructor(
|
||||
override suspend fun entersBackground() = coroutineScope {
|
||||
lockJob = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
|
||||
delay(GRACE_PERIOD_IN_MILLIS)
|
||||
//delay(GRACE_PERIOD_IN_MILLIS)
|
||||
_lockScreenState.value = LockScreenState.Locked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,16 +27,16 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class PinAuthenticationNode @AssistedInject constructor(
|
||||
class PinUnlockNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PinAuthenticationPresenter,
|
||||
private val presenter: PinUnlockPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
PinAuthenticationView(
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.unlock.keypad
|
||||
|
||||
sealed interface CreatePinEvents {
|
||||
data class OnPinEntryChanged(val entryAsText: String) : CreatePinEvents
|
||||
data object ClearFailure : CreatePinEvents
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface PinKeypadModel {
|
||||
data object Empty : PinKeypadModel
|
||||
data object Back : PinKeypadModel
|
||||
data class Number(val number: Char) : PinKeypadModel
|
||||
}
|
||||
@@ -14,8 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
data class PinAuthenticationState(
|
||||
val eventSink: (PinAuthenticationEvents) -> Unit
|
||||
)
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
||||
fun PinEntry.assertText(text: String) {
|
||||
assertThat(toText()).isEqualTo(text)
|
||||
}
|
||||
|
||||
fun PinEntry.assertEmpty() {
|
||||
val isEmpty = digits.all { it is PinDigit.Empty }
|
||||
assertThat(isEmpty).isTrue()
|
||||
}
|
||||
@@ -14,24 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
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.create.model.PinDigit
|
||||
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
|
||||
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.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CreatePinPresenterTest {
|
||||
class SetupPinPresenterTest {
|
||||
|
||||
private val blacklistedPin = PinValidator.BLACKLIST.first()
|
||||
private val blacklistedPin = "1234"
|
||||
private val halfCompletePin = "12"
|
||||
private val completePin = "1235"
|
||||
private val mismatchedPin = "1236"
|
||||
@@ -39,58 +39,58 @@ class CreatePinPresenterTest {
|
||||
@Test
|
||||
fun `present - complete flow`() = runTest {
|
||||
|
||||
val presenter = createCreatePinPresenter()
|
||||
val presenter = createSetupPinPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.createPinFailure).isNull()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(halfCompletePin))
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertText(halfCompletePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.createPinFailure).isNull()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(blacklistedPin))
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(blacklistedPin)
|
||||
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinBlacklisted)
|
||||
state.eventSink(CreatePinEvents.ClearFailure)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinBlacklisted)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
assertThat(state.createPinFailure).isNull()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isTrue()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(mismatchedPin))
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertText(mismatchedPin)
|
||||
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinsDontMatch)
|
||||
state.eventSink(CreatePinEvents.ClearFailure)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDontMatch)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
assertThat(state.createPinFailure).isNull()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isTrue()
|
||||
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
@@ -99,16 +99,7 @@ class CreatePinPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun PinEntry.assertText(text: String) {
|
||||
assertThat(toText()).isEqualTo(text)
|
||||
}
|
||||
|
||||
private fun PinEntry.assertEmpty() {
|
||||
val isEmpty = digits.all { it is PinDigit.Empty }
|
||||
assertThat(isEmpty).isTrue()
|
||||
}
|
||||
|
||||
private fun createCreatePinPresenter(): CreatePinPresenter {
|
||||
return CreatePinPresenter(PinValidator(), aBuildMeta())
|
||||
private fun createSetupPinPresenter(): SetupPinPresenter {
|
||||
return SetupPinPresenter(PinValidator(setOf(blacklistedPin)), aBuildMeta())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
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.utils.CommonDrawables
|
||||
@@ -61,7 +62,7 @@ fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineEncryptedHistoryBannerViewPreview() {
|
||||
ElementTheme {
|
||||
ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -200,7 +201,7 @@ private fun EditLinkDialog(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerLinkDialogCreateLinkPreview() {
|
||||
internal fun TextComposerLinkDialogCreateLinkPreview() = ElementPreview {
|
||||
TextComposerLinkDialog(
|
||||
onDismissRequest = {},
|
||||
linkAction = LinkAction.InsertLink,
|
||||
@@ -212,7 +213,7 @@ internal fun TextComposerLinkDialogCreateLinkPreview() {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() {
|
||||
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() = ElementPreview {
|
||||
TextComposerLinkDialog(
|
||||
onDismissRequest = {},
|
||||
linkAction = LinkAction.SetLink(null),
|
||||
@@ -224,7 +225,7 @@ internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerLinkDialogEditLinkPreview() {
|
||||
internal fun TextComposerLinkDialogEditLinkPreview() = ElementPreview {
|
||||
TextComposerLinkDialog(
|
||||
onDismissRequest = {},
|
||||
linkAction = LinkAction.SetLink("https://element.io"),
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
@@ -46,9 +47,7 @@ internal fun RecordingProgress(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
|
||||
.heightIn(26.dp)
|
||||
|
||||
,
|
||||
.heightIn(26.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -69,6 +68,6 @@ internal fun RecordingProgress(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordingProgressPreview() {
|
||||
internal fun RecordingProgressPreview() = ElementPreview {
|
||||
RecordingProgress()
|
||||
}
|
||||
|
||||
@@ -35,4 +35,26 @@ class KonsistPreviewTest {
|
||||
it.hasNameEndingWith("DarkPreview").not()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' annotation should contain 'ElementPreview' composable`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.assertTrue {
|
||||
it.text.contains("ElementPreview")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Functions with '@PreviewsDayNight' are internal`() {
|
||||
Konsist
|
||||
.scopeFromProject()
|
||||
.functions()
|
||||
.withAllAnnotationsOf(PreviewsDayNight::class)
|
||||
.assertTrue {
|
||||
it.hasInternalModifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
Binary file not shown.
Reference in New Issue
Block a user