diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt index 8e7d0f194d..44006a6da5 100644 --- a/app/src/main/kotlin/io/element/android/x/MainNode.kt +++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt @@ -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().sessionComponentBuilder().client(client).build() mainDaggerComponentOwner.addComponent(identifier, component) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt new file mode 100644 index 0000000000..c94967939e --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -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, +) : BackstackNode( + 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().forEach { it.onFlowCreated(id, inputs.matrixClient) } + val imageLoaderFactory = bindings().loggedInImageLoaderFactory() + Coil.setImageLoader(imageLoaderFactory) + }, + onDestroy = { + plugins().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().forEach { it.onOpenBugReport() } + } + } + createNode(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(), + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index cd5ac726a2..5006218bda 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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, @@ -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( 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().forEach { it.onFlowCreated(id, inputs.matrixClient) } - val imageLoaderFactory = bindings().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().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) } } - diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 4b6d2395ce..85032e751f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -72,15 +72,14 @@ class RootFlowNode @AssistedInject constructor( private val bugReportEntryPoint: BugReportEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, -) : - BackstackNode( - backstack = BackStack( - initialElement = NavTarget.SplashScreen, - savedStateMap = buildContext.savedStateMap, - ), - buildContext = buildContext, - plugins = plugins - ) { +) : BackstackNode( + 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() - createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) + createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) } NavTarget.NotLoggedInFlow -> createNode(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 diff --git a/changelog.d/1149.feature b/changelog.d/1149.feature new file mode 100644 index 0000000000..7f08bbc4e4 --- /dev/null +++ b/changelog.d/1149.feature @@ -0,0 +1 @@ +Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 0dee792464..5154d68be3 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -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) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index 8866154fff..2b515c18a6 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -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, @@ -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(buildContext) } + NavTarget.MigrationScreen -> { + val callback = object : MigrationScreenNode.Callback { + override fun onMigrationFinished() { + lifecycleScope.launch { moveToNextStep() } + } + } + createNode(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() diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt new file mode 100644 index 0000000000..a4c5d16bc4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt @@ -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, + private val presenter: MigrationScreenPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onMigrationFinished() + } + + private fun onMigrationFinished() { + plugins.filterIsInstance().forEach { it.onMigrationFinished() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MigrationScreenView( + state, + onMigrationFinished = ::onMigrationFinished, + modifier = modifier + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt new file mode 100644 index 0000000000..6507130383 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt @@ -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 { + @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 + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.kt new file mode 100644 index 0000000000..fa718990e4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.kt @@ -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 +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt new file mode 100644 index 0000000000..42eeab673a --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt @@ -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() +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.kt new file mode 100644 index 0000000000..a8f20713e4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.kt @@ -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 = {}) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt new file mode 100644 index 0000000000..c39a535e7d --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt @@ -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_" + } +} + diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt index f67c25a21d..108072cba9 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt @@ -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 } diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml index 38bbf4dacb..f1734c9c75 100644 --- a/features/ftue/impl/src/main/res/values-cs/translations.xml +++ b/features/ftue/impl/src/main/res/values-cs/translations.xml @@ -1,5 +1,7 @@ + "Toto je jednorázový proces, děkujeme za čekání." + "Nastavení vašeho účtu" "Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku." "Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici." "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." diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml index d0ebbc31e7..19b445bd50 100644 --- a/features/ftue/impl/src/main/res/values-de/translations.xml +++ b/features/ftue/impl/src/main/res/values-de/translations.xml @@ -1,5 +1,7 @@ + "Dies ist ein einmaliger Vorgang, danke fürs Warten." + "Dein Konto einrichten" "Anrufe, Umfragen, Suche und mehr werden später in diesem Jahr hinzugefügt." "Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein." "Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst." diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml index 313a6a533f..9f431f545d 100644 --- a/features/ftue/impl/src/main/res/values-fr/translations.xml +++ b/features/ftue/impl/src/main/res/values-fr/translations.xml @@ -1,5 +1,7 @@ + "Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter." + "Configuration de votre compte." "L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour." "Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres." "C’est parti !" diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml index db4fcd21fc..d72497bf98 100644 --- a/features/ftue/impl/src/main/res/values-ru/translations.xml +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -1,5 +1,7 @@ + "Это одноразовый процесс, спасибо, что подождали." + "Настройка учетной записи." "Звонки, опросы, поиск и многое другое будут добавлены позже в этом году." "История сообщений для зашифрованных комнат в этом обновлении будет недоступна." "Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек." diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml index 2438518334..5bbd2d386d 100644 --- a/features/ftue/impl/src/main/res/values-sk/translations.xml +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -1,5 +1,7 @@ + "Ide o jednorazový proces, ďakujeme za trpezlivosť." + "Nastavenie vášho účtu." "Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." "História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii." "Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení." diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml index 6c5d482cb8..b8b510aac5 100644 --- a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml @@ -1,4 +1,5 @@ + "設定您的帳號" "開始吧!" diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 05cc72034e..aee8470751 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -1,5 +1,7 @@ + "This is a one time process, thanks for waiting." + "Setting up your account." "Calls, polls, search and more will be added later this year." "Message history for encrypted rooms won’t be available in this update." "We’d love to hear from you, let us know what you think via the settings page." diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt index cfd489ea2a..908683227f 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt @@ -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() - // 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, + ) } diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/InMemoryMigrationScreenStore.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/InMemoryMigrationScreenStore.kt new file mode 100644 index 0000000000..a77a4d001a --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/InMemoryMigrationScreenStore.kt @@ -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() + + 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() + } +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt new file mode 100644 index 0000000000..6e19879b86 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenterTest.kt @@ -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, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt index 1bce4b39a9..5ac53e851e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -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), + ) + } + } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt new file mode 100644 index 0000000000..6d4cc321a0 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt @@ -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() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt new file mode 100644 index 0000000000..10a6f529af --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt @@ -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 = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt index 6b16ff96e7..779b7e7053 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt @@ -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, + ) + } +} diff --git a/features/login/impl/src/main/res/drawable/light_dark.png b/libraries/designsystem/src/main/res/drawable/light_dark.png similarity index 100% rename from features/login/impl/src/main/res/drawable/light_dark.png rename to libraries/designsystem/src/main/res/drawable/light_dark.png diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index ee1b221ad7..1ace446132 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -178,8 +178,6 @@ "Výběr média se nezdařil, zkuste to prosím znovu." "Nahrání média se nezdařilo, zkuste to prosím znovu." "Nahrání média se nezdařilo, zkuste to prosím znovu." - "Toto je jednorázový proces, děkujeme za čekání." - "Nastavení vašeho účtu" "Další nastavení" "Halsové a video hovory" "Neshoda konfigurace" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 68223c8513..00979596a9 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -176,8 +176,6 @@ "Medienauswahl fehlgeschlagen, bitte versuche es erneut." "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut." "Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut." - "Dies ist ein einmaliger Vorgang, danke fürs Warten." - "Dein Konto einrichten" "Zusätzliche Einstellungen" "Audio- und Videoanrufe" "Konfigurationskonflikt" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index e3fe11dfdc..e7020bc47b 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -165,8 +165,6 @@ "Impossible de sélectionner un média, veuillez réessayer." "Échec du traitement du média avant son envoi, veuillez réessayer." "Impossible d’envoyer le média, veuillez réessayer." - "Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter." - "Configuration de votre compte." "Activer les notifications sur cet appareil" "paramètres système" "Notifications système désactivées" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 865eace823..23df74fff5 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -178,8 +178,6 @@ "Не удалось выбрать носитель, попробуйте еще раз." "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." "Не удалось загрузить медиафайлы, попробуйте еще раз." - "Это одноразовый процесс, спасибо, что подождали." - "Настройка учетной записи." "Дополнительные параметры" "Аудио и видео звонки" "Несоответствие конфигурации" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index a070f874e9..b8d919faa0 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -178,8 +178,6 @@ "Nepodarilo sa vybrať médium, skúste to prosím znova." "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." "Nepodarilo sa nahrať médiá, skúste to prosím znova." - "Ide o jednorazový proces, ďakujeme za trpezlivosť." - "Nastavenie vášho účtu." "Ďalšie nastavenia" "Audio a video hovory" "Nezhoda konfigurácie" diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml index 27230b0e9b..32f8a23086 100644 --- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -144,7 +144,6 @@ "新訊息" "分享分析數據" "無法上傳媒體檔案,請稍後再試。" - "設定您的帳號" "其他設定" "私訊" "在這個裝置上開啟通知" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index db58894472..4f25e8f07b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -176,8 +176,6 @@ "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." - "This is a one time process, thanks for waiting." - "Setting up your account." "Additional settings" "Audio and video calls" "Configuration mismatch" diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..99a9e03973 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c9d9aa75b2b01e9b0106377fbcd88a92fb8b4d6a34323b18264fed8f9a48cab +size 133347 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..99a9e03973 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.migration_null_MigrationView-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c9d9aa75b2b01e9b0106377fbcd88a92fb8b4d6a34323b18264fed8f9a48cab +size 133347 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-1_2_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-0_1_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-1_2_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-1_3_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-0_2_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-1_3_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9a5371804e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ca2afda159bdd7f57e78b04fbe3c022a9aeee1b68f72cf4d4269b3e503f0e5 +size 127335 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9a5371804e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.atomic.pages_null_SunsetPage-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ca2afda159bdd7f57e78b04fbe3c022a9aeee1b68f72cf4d4269b3e503f0e5 +size 127335 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index fce6b317b5..97acdf2ab5 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -117,7 +117,8 @@ { "name": ":features:ftue:impl", "includeRegex": [ - "screen_welcome_.*" + "screen_welcome_.*", + "screen_migration_.*" ] } ]