Merge pull request #1149 from vector-im/feature/bma/settingUpAccount

"Setting up account" screen
This commit is contained in:
Benoit Marty 2023-08-28 14:26:53 +02:00 committed by GitHub
commit 16b4db732a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 864 additions and 212 deletions

View file

@ -27,7 +27,7 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.appnav.LoggedInFlowNode
import io.element.android.appnav.LoggedInAppScopeFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.appnav.RootFlowNode
import io.element.android.libraries.architecture.bindings
@ -56,7 +56,7 @@ class MainNode(
),
DaggerComponentOwner by mainDaggerComponentOwner {
private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback {
private val loggedInFlowNodeCallback = object : LoggedInAppScopeFlowNode.LifecycleCallback {
override fun onFlowCreated(identifier: String, client: MatrixClient) {
val component = bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(client).build()
mainDaggerComponentOwner.addComponent(identifier, component)

View file

@ -0,0 +1,124 @@
/*
* 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.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil.Coil
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
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.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import kotlinx.parcelize.Parcelize
/**
* `LoggedInAppScopeFlowNode` is a Node responsible to set up the Dagger
* [io.element.android.libraries.di.SessionScope]. It has only one child: [LoggedInFlowNode].
* This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode].
*/
@ContributesNode(AppScope::class)
class LoggedInAppScopeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<LoggedInAppScopeFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun onOpenBugReport()
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, client: MatrixClient)
fun onFlowReleased(identifier: String, client: MatrixClient)
}
data class Inputs(
val matrixClient: MatrixClient
) : NodeInputs
private val inputs: Inputs = inputs()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
},
onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : LoggedInFlowNode.Callback {
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
}
createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
}
}
suspend fun attachSession(): LoggedInFlowNode {
return waitForChildAttached { navTarget ->
navTarget is NavTarget.Root
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import coil.Coil
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@ -53,19 +52,15 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
@ -76,7 +71,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
@ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -91,6 +86,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@ -105,32 +101,18 @@ class LoggedInFlowNode @AssistedInject constructor(
fun onOpenBugReport()
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, client: MatrixClient)
fun onFlowReleased(identifier: String, client: MatrixClient)
}
data class Inputs(
val matrixClient: MatrixClient
) : NodeInputs
private val inputs: Inputs = inputs()
private val syncService = inputs.matrixClient.syncService()
private val syncService = matrixClient.syncService()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
inputs.matrixClient.roomMembershipObserver(),
inputs.matrixClient.sessionVerificationService(),
matrixClient.roomMembershipObserver(),
matrixClient.sessionVerificationService(),
)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
appNavigationStateService.onNavigateToSession(id, inputs.matrixClient.sessionId)
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
@ -146,7 +128,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
},
onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
@ -351,4 +332,3 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.InviteList)
}
}

View file

@ -72,15 +72,14 @@ class RootFlowNode @AssistedInject constructor(
private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
) : BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
override fun onBuilt() {
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
@ -191,14 +190,14 @@ class RootFlowNode @AssistedInject constructor(
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInFlowNode.Inputs(matrixClient)
val callback = object : LoggedInFlowNode.Callback {
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
}
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
createNode<LoggedInFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
}
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
NavTarget.SplashScreen -> splashNode(buildContext)
@ -233,6 +232,7 @@ class RootFlowNode @AssistedInject constructor(
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId)
.attachSession()
.apply {
when (deeplinkData) {
is DeeplinkData.Root -> attachRoot()
@ -246,7 +246,7 @@ class RootFlowNode @AssistedInject constructor(
oidcActionFlow.post(oidcAction)
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
private suspend fun attachSession(sessionId: SessionId): LoggedInAppScopeFlowNode {
//TODO handle multi-session
return waitForChildAttached { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId

1
changelog.d/1149.feature Normal file
View file

@ -0,0 +1 @@
Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account).

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.ftue.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

View file

@ -34,6 +34,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
@ -41,6 +42,7 @@ import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -50,7 +52,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@ContributesNode(SessionScope::class)
class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -71,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object Placeholder : NavTarget
@Parcelize
data object MigrationScreen : NavTarget
@Parcelize
data object WelcomeScreen : NavTarget
@ -102,6 +107,14 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.MigrationScreen -> {
val callback = object : MigrationScreenNode.Callback {
override fun onMigrationFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<MigrationScreenNode>(buildContext, listOf(callback))
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
@ -117,12 +130,15 @@ class FtueFlowNode @AssistedInject constructor(
}
}
private suspend fun moveToNextStep() {
private fun moveToNextStep() {
when (ftueState.getNextStep()) {
is FtueStep.WelcomeScreen -> {
FtueStep.MigrationScreen -> {
backstack.newRoot(NavTarget.MigrationScreen)
}
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
is FtueStep.AnalyticsOptIn -> {
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
null -> callback?.onFtueFlowFinished()

View file

@ -0,0 +1,53 @@
/*
* 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.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class MigrationScreenNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MigrationScreenPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onMigrationFinished()
}
private fun onMigrationFinished() {
plugins.filterIsInstance<Callback>().forEach { it.onMigrationFinished() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MigrationScreenView(
state,
onMigrationFinished = ::onMigrationFinished,
modifier = modifier
)
}
}

View file

@ -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.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import javax.inject.Inject
class MigrationScreenPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val migrationScreenStore: MigrationScreenStore,
) : Presenter<MigrationScreenState> {
@Composable
override fun present(): MigrationScreenState {
val roomListState by matrixClient.roomListService.state.collectAsState()
if (roomListState == RoomListService.State.Running) {
LaunchedEffect(Unit) {
migrationScreenStore.setMigrationScreenShown(matrixClient.sessionId)
}
}
return MigrationScreenState(
isMigrating = roomListState != RoomListService.State.Running
)
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.ftue.impl.migration
data class MigrationScreenState(
val isMigrating: Boolean
)

View file

@ -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.ftue.impl.migration
import io.element.android.libraries.matrix.api.core.SessionId
interface MigrationScreenStore {
fun isMigrationScreenNeeded(sessionId: SessionId): Boolean
fun setMigrationScreenShown(sessionId: SessionId)
fun reset()
}

View file

@ -0,0 +1,54 @@
/*
* 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.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
@Composable
fun MigrationScreenView(
migrationState: MigrationScreenState,
onMigrationFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
if (migrationState.isMigrating.not()) {
LaunchedEffect(Unit) {
onMigrationFinished()
}
}
SunsetPage(
modifier = modifier,
isLoading = true,
title = stringResource(id = R.string.screen_migration_title),
subtitle = stringResource(id = R.string.screen_migration_message),
overallContent = {}
)
}
@DayNightPreviews
@Composable
internal fun MigrationViewPreview() = ElementPreview {
MigrationScreenView(
migrationState = MigrationScreenState(isMigrating = true),
onMigrationFinished = {})
}

View file

@ -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.ftue.impl.migration
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class SharedPrefsMigrationScreenStore @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
) : MigrationScreenStore {
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
return sharedPreferences.getBoolean(sessionId.toKey(), false).not()
}
override fun setMigrationScreenShown(sessionId: SessionId) {
sharedPreferences.edit().putBoolean(sessionId.toKey(), true).apply()
}
override fun reset() {
sharedPreferences.edit {
sharedPreferences.all.keys
.filter { it.startsWith(IS_MIGRATION_SCREEN_SHOWN_PREFIX) }
.forEach {
remove(it)
}
}
}
private fun SessionId.toKey(): String {
// Hash the sessionId to get rid of exotic char and take only the first 16 chars,
// The risk of collision is not high.
return IS_MIGRATION_SCREEN_SHOWN_PREFIX + value.hash().take(16)
}
companion object {
private const val IS_MIGRATION_SCREEN_SHOWN_PREFIX = "is_migration_screen_shown_"
}
}

View file

@ -19,8 +19,10 @@ package io.element.android.features.ftue.impl.state
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@ -30,11 +32,13 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor(
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val matrixClient: MatrixClient,
) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
@ -42,6 +46,7 @@ class DefaultFtueState @Inject constructor(
override suspend fun reset() {
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
}
init {
@ -52,7 +57,10 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
null -> if (shouldDisplayMigrationScreen()) FtueStep.MigrationScreen else getNextStep(
FtueStep.MigrationScreen
)
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
@ -63,11 +71,16 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
needsAnalyticsOptIn()
).any { it }
}
private fun shouldDisplayMigrationScreen(): Boolean {
return migrationScreenStore.isMigrationScreenNeeded(matrixClient.sessionId)
}
private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
@ -89,6 +102,7 @@ class DefaultFtueState @Inject constructor(
}
sealed interface FtueStep {
data object MigrationScreen : FtueStep
data object WelcomeScreen : FtueStep
data object AnalyticsOptIn : FtueStep
}

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>
<string name="screen_welcome_bullet_3">"Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto einrichten"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Ce processus na besoin dêtre fait quune seule fois, merci de patienter."</string>
<string name="screen_migration_title">"Configuration de votre compte."</string>
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string>
<string name="screen_welcome_button">"Cest parti !"</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
<string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_title">"設定您的帳號"</string>
<string name="screen_welcome_button">"開始吧!"</string>
</resources>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -18,9 +18,14 @@ package io.element.android.features.ftue.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -45,12 +50,14 @@ class DefaultFtueStateTests {
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@ -63,16 +70,21 @@ class DefaultFtueStateTests {
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val steps = mutableListOf<FtueStep?>()
// First step, welcome screen
// First step, migration screen
steps.add(state.getNextStep(steps.lastOrNull()))
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
// Second step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Second step, analytics opt in
// Third step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@ -80,6 +92,7 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.AnalyticsOptIn,
null, // Final state
@ -93,7 +106,16 @@ class DefaultFtueStateTests {
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -108,7 +130,14 @@ class DefaultFtueStateTests {
private fun createState(
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService()
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
matrixClient: MatrixClient = FakeMatrixClient(),
) = DefaultFtueState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
matrixClient = matrixClient,
)
}

View file

@ -0,0 +1,36 @@
/*
* 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.ftue.impl.migration
import io.element.android.libraries.matrix.api.core.SessionId
class InMemoryMigrationScreenStore : MigrationScreenStore {
private val store = mutableMapOf<SessionId, Boolean>()
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
// If store does not have key return true, else return the opposite of the value
return store[sessionId]?.not() ?: true
}
override fun setMigrationScreenShown(sessionId: SessionId) {
store[sessionId] = true
}
override fun reset() {
store.clear()
}
}

View file

@ -0,0 +1,69 @@
/*
* 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.ftue.impl.migration
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.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MigrationScreenPresenterTest {
@Test
fun `present - initial`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isMigrating).isTrue()
}
}
@Test
fun `present - migration end`() = runTest {
val matrixClient = FakeMatrixClient()
val migrationScreenStore = InMemoryMigrationScreenStore()
val presenter = createPresenter(matrixClient, migrationScreenStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isMigrating).isTrue()
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isTrue()
// Simulate room list loaded
(matrixClient.roomListService as FakeRoomListService).postState(RoomListService.State.Running)
val nextState = awaitItem()
assertThat(nextState.isMigrating).isFalse()
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isFalse()
}
}
private fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
) = MigrationScreenPresenter(
matrixClient,
migrationScreenStore,
)
}

View file

@ -16,32 +16,17 @@
package io.element.android.features.login.impl.screens.waitlistscreen
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.layout.widthIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAbsoluteAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -50,15 +35,13 @@ import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.isWaitListError
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
@ -75,12 +58,7 @@ fun WaitListView(
else -> Unit
}
}
Box(modifier = modifier) {
WaitListBackground()
WaitListContent(state, onCancelClicked)
WaitListError(state)
}
WaitListContent(state, onCancelClicked, modifier)
}
@Composable
@ -101,136 +79,69 @@ private fun WaitListError(state: WaitListState) {
}
}
@Composable
private fun WaitListBackground(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.3f)
.background(Color.White)
)
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(id = R.drawable.light_dark),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.7f)
.background(Color(0xFF121418))
)
}
}
@Composable
private fun WaitListContent(
state: WaitListState,
onCancelClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
ElementTheme(
darkTheme = true
Box(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
if (state.loginAction !is Async.Success) {
CompositionLocalProvider(LocalContentColor provides Color.Black) {
TextButton(
text = stringResource(CommonStrings.action_cancel),
onClick = onCancelClicked,
)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAbsoluteAlignment(
horizontalBias = 0f,
verticalBias = -0.05f
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (state.loginAction.isLoading()) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ElementTheme.colors.iconPrimary
)
} else {
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height(18.dp))
val titleRes = when (state.loginAction) {
is Async.Success -> R.string.screen_waitlist_title_success
else -> R.string.screen_waitlist_title
}
Text(
text = withColoredPeriod(titleRes),
style = ElementTheme.typography.fontHeadingXlBold,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
Spacer(modifier = Modifier.height(8.dp))
val subtitle = when (state.loginAction) {
is Async.Success -> stringResource(
id = R.string.screen_waitlist_message_success,
state.appName,
)
else -> stringResource(
id = R.string.screen_waitlist_message,
state.appName,
state.serverName,
)
}
Text(
modifier = Modifier.widthIn(max = 360.dp),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
}
}
if (state.loginAction is Async.Success) {
Button(
text = stringResource(id = CommonStrings.action_continue),
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp),
)
val title = stringResource(
when (state.loginAction) {
is Async.Success -> R.string.screen_waitlist_title_success
else -> R.string.screen_waitlist_title
}
)
val subtitle = when (state.loginAction) {
is Async.Success -> stringResource(
id = R.string.screen_waitlist_message_success,
state.appName,
)
else -> stringResource(
id = R.string.screen_waitlist_message,
state.appName,
state.serverName,
)
}
SunsetPage(
isLoading = state.loginAction.isLoading(),
title = title,
subtitle = subtitle,
) {
OverallContent(state, onCancelClicked)
}
WaitListError(state)
}
}
@Composable
private fun withColoredPeriod(
@StringRes textRes: Int,
) = buildAnnotatedString {
val text = stringResource(textRes)
append(text)
if (text.endsWith(".")) {
addStyle(
style = SpanStyle(
// Light.colorGreen700
color = Color(0xff0bc491),
),
start = text.length - 1,
end = text.length,
)
private fun OverallContent(
state: WaitListState,
onCancelClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
if (state.loginAction !is Async.Success) {
CompositionLocalProvider(LocalContentColor provides Color.Black) {
TextButton(
text = stringResource(CommonStrings.action_cancel),
onClick = onCancelClicked,
)
}
}
if (state.loginAction is Async.Success) {
Button(
text = stringResource(id = CommonStrings.action_continue),
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp),
)
}
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.libraries.androidutils.hash
import java.security.MessageDigest
import java.util.Locale
/**
* Compute a Hash of a String, using SHA-512 algorithm.
*/
fun String.hash() = try {
val digest = MessageDigest.getInstance("SHA-512")
digest.update(toByteArray())
digest.digest()
.joinToString("") { String.format(Locale.ROOT, "%02X", it) }
.lowercase(Locale.ROOT)
} catch (exc: Exception) {
// Should not happen, but just in case
hashCode().toString()
}

View file

@ -0,0 +1,147 @@
/*
* 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.libraries.designsystem.atomic.pages
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAbsoluteAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.withColoredPeriod
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
fun SunsetPage(
isLoading: Boolean,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
overallContent: @Composable () -> Unit,
) {
ElementTheme(
darkTheme = true
) {
Box(
modifier = modifier.fillMaxSize()
) {
SunsetBackground()
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAbsoluteAlignment(
horizontalBias = 0f,
verticalBias = -0.05f
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ElementTheme.colors.iconPrimary
)
} else {
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = withColoredPeriod(title),
style = ElementTheme.typography.fontHeadingXlBold,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier.widthIn(max = 360.dp),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
}
}
overallContent()
}
}
}
}
@Composable
private fun SunsetBackground(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.3f)
.background(Color.White)
)
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(id = R.drawable.light_dark),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.7f)
.background(Color(0xFF121418))
)
}
}
@DayNightPreviews
@Composable
internal fun SunsetPagePreview() = ElementPreview {
SunsetPage(
isLoading = true,
title = "Title with a green period.",
subtitle = "Subtitle",
overallContent = {}
)
}

View file

@ -82,3 +82,22 @@ fun buildAnnotatedStringWithStyledPart(
end = startIndex + coloredPart.length,
)
}
/**
* Convert a string to an [AnnotatedString] with colored end period if present.
*/
fun withColoredPeriod(
text: String,
) = buildAnnotatedString {
append(text)
if (text.endsWith(".")) {
addStyle(
style = SpanStyle(
// Light.colorGreen700
color = Color(0xff0bc491),
),
start = text.length - 1,
end = text.length,
)
}
}

View file

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before After
Before After

View file

@ -178,8 +178,6 @@
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Další nastavení"</string>
<string name="screen_notification_settings_calls_label">"Halsové a video hovory"</string>
<string name="screen_notification_settings_configuration_mismatch">"Neshoda konfigurace"</string>

View file

@ -176,8 +176,6 @@
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut."</string>
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto einrichten"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Zusätzliche Einstellungen"</string>
<string name="screen_notification_settings_calls_label">"Audio- und Videoanrufe"</string>
<string name="screen_notification_settings_configuration_mismatch">"Konfigurationskonflikt"</string>

View file

@ -165,8 +165,6 @@
<string name="screen_media_picker_error_failed_selection">"Impossible de sélectionner un média, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement du média avant son envoi, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Impossible denvoyer le média, veuillez réessayer."</string>
<string name="screen_migration_message">"Ce processus na besoin dêtre fait quune seule fois, merci de patienter."</string>
<string name="screen_migration_title">"Configuration de votre compte."</string>
<string name="screen_notification_settings_enable_notifications">"Activer les notifications sur cet appareil"</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"paramètres système"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notifications système désactivées"</string>

View file

@ -178,8 +178,6 @@
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_notification_settings_additional_settings_section_title">"Дополнительные параметры"</string>
<string name="screen_notification_settings_calls_label">"Аудио и видео звонки"</string>
<string name="screen_notification_settings_configuration_mismatch">"Несоответствие конфигурации"</string>

View file

@ -178,8 +178,6 @@
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
<string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
<string name="screen_notification_settings_additional_settings_section_title">"Ďalšie nastavenia"</string>
<string name="screen_notification_settings_calls_label">"Audio a video hovory"</string>
<string name="screen_notification_settings_configuration_mismatch">"Nezhoda konfigurácie"</string>

View file

@ -144,7 +144,6 @@
<string name="room_timeline_read_marker_title">"新訊息"</string>
<string name="screen_analytics_settings_share_data">"分享分析數據"</string>
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
<string name="screen_migration_title">"設定您的帳號"</string>
<string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string>
<string name="screen_notification_settings_direct_chats">"私訊"</string>
<string name="screen_notification_settings_enable_notifications">"在這個裝置上開啟通知"</string>

View file

@ -176,8 +176,6 @@
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
<string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c9d9aa75b2b01e9b0106377fbcd88a92fb8b4d6a34323b18264fed8f9a48cab
size 133347

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c9d9aa75b2b01e9b0106377fbcd88a92fb8b4d6a34323b18264fed8f9a48cab
size 133347

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50ca2afda159bdd7f57e78b04fbe3c022a9aeee1b68f72cf4d4269b3e503f0e5
size 127335

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50ca2afda159bdd7f57e78b04fbe3c022a9aeee1b68f72cf4d4269b3e503f0e5
size 127335

View file

@ -117,7 +117,8 @@
{
"name": ":features:ftue:impl",
"includeRegex": [
"screen_welcome_.*"
"screen_welcome_.*",
"screen_migration_.*"
]
}
]