1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-3476 Migration | Enhancement | Update migration UX flow (#2168)

This commit is contained in:
Evgenii Kozlov 2025-03-19 11:32:24 +01:00 committed by GitHub
parent 1cd15e2dde
commit e8194734db
Signed by: github
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 98 deletions

View file

@ -0,0 +1,45 @@
package com.anytypeio.anytype.ui.onboarding.screens.signin
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import com.anytypeio.anytype.core_ui.MnemonicPhrasePaletteColors
object MnemonicPhraseFormatter : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val transformed = buildAnnotatedString {
var colorIndex = 0
var isPreviousLetterOrDigit = false
text.forEachIndexed { index, char ->
if (char.isLetterOrDigit()) {
withStyle(
style = SpanStyle(
color = MnemonicPhrasePaletteColors[colorIndex]
)
) {
append(char)
}
isPreviousLetterOrDigit = true
} else {
if (isPreviousLetterOrDigit) {
colorIndex = colorIndex.inc()
isPreviousLetterOrDigit = false
}
append(char)
}
if (colorIndex > MnemonicPhrasePaletteColors.lastIndex) {
colorIndex = 0
}
}
}
return TransformedText(
transformed,
OffsetMapping.Identity
)
}
}

View file

@ -1,6 +1,5 @@
package com.anytypeio.anytype.ui.onboarding.screens.signin
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -27,23 +26,14 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.BuildConfig
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_ui.ColorButtonRegular
import com.anytypeio.anytype.core_ui.MnemonicPhrasePaletteColors
import com.anytypeio.anytype.core_ui.OnBoardingTextPrimaryColor
import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.views.ButtonSize
@ -57,6 +47,7 @@ import com.anytypeio.anytype.presentation.onboarding.login.OnboardingMnemonicLog
import com.anytypeio.anytype.ui.onboarding.OnboardingMnemonicInput
import com.anytypeio.anytype.ui.update.MigrationFailedScreen
import com.anytypeio.anytype.ui.update.MigrationInProgressScreen
import com.anytypeio.anytype.ui.update.MigrationStartScreen
import kotlin.Unit
@Composable
@ -75,7 +66,8 @@ fun RecoveryScreenWrapper(
onDebugAccountTraceClicked = {
vm.onAccountThraceButtonClicked()
},
onRetryMigrationClicked = vm::onRetryMigrationClicked
onRetryMigrationClicked = vm::onRetryMigrationClicked,
onStartMigrationClicked = vm::onStartMigrationClicked
)
}
@ -88,7 +80,8 @@ fun RecoveryScreen(
state: SetupState,
onEnterMyVaultClicked: () -> Unit,
onDebugAccountTraceClicked: () -> Unit,
onRetryMigrationClicked: (Id) -> Unit
onRetryMigrationClicked: (Id) -> Unit,
onStartMigrationClicked: (Id) -> Unit
) {
val focus = LocalFocusManager.current
val context = LocalContext.current
@ -223,84 +216,30 @@ fun RecoveryScreen(
}
}
)
if (state is SetupState.Migration.InProgress) {
MigrationInProgressScreen()
} else if(state is SetupState.Migration.Failed) {
MigrationFailedScreen(
state = state.state,
onRetryClicked = {
onRetryMigrationClicked(state.account)
if (state is SetupState.Migration) {
when(state) {
is SetupState.Migration.Failed -> {
MigrationFailedScreen(
state = state.state,
onRetryClicked = {
onRetryMigrationClicked(state.account)
}
)
}
)
}
}
}
typealias Mnemonic = String
object MnemonicPhraseFormatter : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val transformed = buildAnnotatedString {
var colorIndex = 0
var isPreviousLetterOrDigit = false
text.forEachIndexed { index, char ->
if (char.isLetterOrDigit()) {
withStyle(
style = SpanStyle(
color = MnemonicPhrasePaletteColors[colorIndex]
)
) {
append(char)
}
isPreviousLetterOrDigit = true
} else {
if (isPreviousLetterOrDigit) {
colorIndex = colorIndex.inc()
isPreviousLetterOrDigit = false
}
append(char)
is SetupState.Migration.InProgress -> {
MigrationInProgressScreen()
}
if (colorIndex > MnemonicPhrasePaletteColors.lastIndex) {
colorIndex = 0
is SetupState.Migration.AwaitingStart -> {
MigrationStartScreen(
onStartUpdate = {
onStartMigrationClicked(state.account)
}
)
}
}
}
return TransformedText(
transformed,
OffsetMapping.Identity
)
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
@Composable
fun RecoveryScreenPreview() {
RecoveryScreen(
onBackClicked = {},
onNextClicked = {},
onActionDoneClicked = {},
onScanQrClicked = {},
state = SetupState.Idle,
onEnterMyVaultClicked = {},
onDebugAccountTraceClicked = {},
onRetryMigrationClicked = {}
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
@Composable
fun RecoveryScreenLoadingPreview() {
RecoveryScreen(
onBackClicked = {},
onNextClicked = {},
onActionDoneClicked = {},
onScanQrClicked = {},
state = SetupState.InProgress,
onEnterMyVaultClicked = {},
onDebugAccountTraceClicked = {},
onRetryMigrationClicked = {}
)
}
typealias Mnemonic = String

View file

@ -0,0 +1,37 @@
package com.anytypeio.anytype.ui.onboarding.screens.signin
import androidx.compose.runtime.Composable
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.presentation.onboarding.login.OnboardingMnemonicLoginViewModel.SetupState
@DefaultPreviews
@Composable
private fun RecoveryScreenPreview() {
RecoveryScreen(
onBackClicked = {},
onNextClicked = {},
onActionDoneClicked = {},
onScanQrClicked = {},
state = SetupState.Idle,
onEnterMyVaultClicked = {},
onDebugAccountTraceClicked = {},
onRetryMigrationClicked = {},
onStartMigrationClicked = {}
)
}
@DefaultPreviews
@Composable
private fun RecoveryScreenLoadingPreview() {
RecoveryScreen(
onBackClicked = {},
onNextClicked = {},
onActionDoneClicked = {},
onScanQrClicked = {},
state = SetupState.InProgress,
onEnterMyVaultClicked = {},
onDebugAccountTraceClicked = {},
onRetryMigrationClicked = {},
onStartMigrationClicked = {}
)
}

View file

@ -32,6 +32,7 @@ import com.anytypeio.anytype.ui.onboarding.OnboardingFragment
import com.anytypeio.anytype.ui.sets.ObjectSetFragment
import com.anytypeio.anytype.ui.update.MigrationFailedScreen
import com.anytypeio.anytype.ui.update.MigrationInProgressScreen
import com.anytypeio.anytype.ui.update.MigrationStartScreen
import com.anytypeio.anytype.ui.vault.VaultFragment
import javax.inject.Inject
import kotlinx.coroutines.launch
@ -81,13 +82,21 @@ class SplashFragment : BaseFragment<FragmentSplashBinding>(R.layout.fragment_spl
}
is SplashViewModel.State.Migration -> {
binding.compose.setContent {
if (state is SplashViewModel.State.Migration.InProgress) {
MigrationInProgressScreen()
} else if (state is SplashViewModel.State.Migration.Failed) {
MigrationFailedScreen(
state = state.state,
onRetryClicked = vm::onRetryMigrationClicked
)
when(state) {
is SplashViewModel.State.Migration.AwaitingStart -> {
MigrationStartScreen(
onStartUpdate = vm::onStartMigrationClicked
)
}
is SplashViewModel.State.Migration.InProgress -> {
MigrationInProgressScreen()
}
is SplashViewModel.State.Migration.Failed -> {
MigrationFailedScreen(
state = state.state,
onRetryClicked = vm::onRetryMigrationClicked
)
}
}
}
binding.compose.visible()

View file

@ -9,9 +9,16 @@ 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.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -23,13 +30,174 @@ import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.foundation.AlertConfig
import com.anytypeio.anytype.core_ui.foundation.AlertIcon
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.foundation.GRADIENT_TYPE_RED
import com.anytypeio.anytype.core_ui.views.BodyCalloutRegular
import com.anytypeio.anytype.core_ui.views.ButtonPrimary
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineHeading
import com.anytypeio.anytype.core_ui.views.HeadlineSubheading
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate
@Composable
fun MigrationStartScreen(
onStartUpdate: () -> Unit
) {
var showReadMoreView by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(color = colorResource(R.color.background_primary))
,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.Center)
) {
// TODO add icon
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.migration_screen_new_version_update),
style = HeadlineTitle,
color = colorResource(R.color.text_primary),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.migration_screen_description_1),
style = BodyCalloutRegular,
color = colorResource(R.color.text_secondary),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.migration_screen_description_2),
style = BodyCalloutRegular,
color = colorResource(R.color.text_secondary),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.BottomCenter)
) {
ButtonPrimary(
modifier = Modifier.fillMaxWidth(),
onClick = onStartUpdate,
text = stringResource(R.string.migration_screen_start_update),
size = ButtonSize.Large
)
Spacer(modifier = Modifier.height(12.dp))
ButtonSecondary(
modifier = Modifier.fillMaxWidth(),
onClick = { showReadMoreView = true },
text = stringResource(R.string.migration_screen_read_more),
size = ButtonSize.Large
)
Spacer(modifier = Modifier.height(32.dp))
}
}
if (showReadMoreView) {
MigrationReadMoreBottomSheet(
onDismissRequest = {
showReadMoreView = false
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MigrationReadMoreBottomSheet(
onDismissRequest: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
dragHandle = {
Dragger(
modifier = Modifier.padding(vertical = 6.dp)
)
},
containerColor = colorResource(R.color.background_secondary),
content = {
MigrationReadMoreScreenContent()
}
)
}
@Composable
fun MigrationReadMoreScreenContent() {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(
horizontal = 16.dp
)
) {
item {
Spacer(modifier = Modifier.height(44.dp))
// TODO add icon
Text(
text = stringResource(R.string.migration_screen_what_to_expect),
style = HeadlineSubheading,
color = colorResource(R.color.text_primary)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.migration_screen_what_to_expect_description),
style = BodyCalloutRegular,
color = colorResource(R.color.text_secondary)
)
}
item {
Spacer(modifier = Modifier.height(32.dp))
// TODO add icon
Text(
text = stringResource(R.string.migration_screen_your_data_remains_safe),
style = HeadlineSubheading,
color = colorResource(R.color.text_primary)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.migration_screen_your_data_description),
style = BodyCalloutRegular,
color = colorResource(R.color.text_secondary)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.migration_screen_your_data_description_2),
style = BodyCalloutRegular,
color = colorResource(R.color.text_secondary)
)
Spacer(modifier = Modifier.height(44.dp))
}
}
}
@DefaultPreviews
@Composable
fun MigrationReadMoreScreenPreview() {
MigrationReadMoreScreenContent()
}
@DefaultPreviews
@Composable
fun MigrationStartScreenPreview() {
MigrationStartScreen(
onStartUpdate = {}
)
}
@Composable
fun MigrationInProgressScreen() {
Box(
@ -115,7 +283,9 @@ fun MigrationFailedScreen(
text = description,
color = colorResource(R.color.text_secondary),
style = BodyCalloutRegular,
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
modifier = Modifier
.padding(horizontal = 20.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center
)
}

View file

@ -1985,6 +1985,16 @@ Please provide specific details of your needs here.</string>
<string name="property_edit_menu_delete">Delete from space</string>
<string name="property_edit_menu_unlink">Unlink from type</string>
<string name="delete_space_checkbox_text">I have read and want to delete this space</string>
<string name="migration_screen_new_version_update">New Version Update</string>
<string name="migration_screen_description_1">We\'re laying the groundwork for our new chats. Including counters, notifications and other features needed for smooth chat experience.</string>
<string name="migration_screen_description_2">It might take a little while, but don\'t worry, your data is safe.</string>
<string name="migration_screen_start_update">Start Update</string>
<string name="migration_screen_read_more">Read More</string>
<string name="migration_screen_what_to_expect">What to Expect</string>
<string name="migration_screen_your_data_remains_safe">Your Data Remains Safe</string>
<string name="migration_screen_your_data_description">During this update, your data remains fully secure. The update is performed directly on your device, and your synced data remains unaffected. Well just copy it to a new format, and a local backup will be created on your device, containing all your data in the previous format.</string>
<string name="migration_screen_your_data_description_2">The reason were retaining this backup is to debug and assist you, in case of unforeseen.</string>
<string name="migration_screen_what_to_expect_description">You\'ll see a loading screen during the update. Once finished, you can continue using the app normally.</string>
<string name="object_type_icon_change_title">Change icon</string>
<string name="object_type_icon_remove">Remove</string>

View file

@ -15,7 +15,6 @@ import com.anytypeio.anytype.core_models.exceptions.NeedToUpdateApplicationExcep
import com.anytypeio.anytype.core_utils.ext.cancel
import com.anytypeio.anytype.domain.auth.interactor.ConvertWallet
import com.anytypeio.anytype.domain.auth.interactor.Logout
import com.anytypeio.anytype.domain.auth.interactor.MigrateAccount
import com.anytypeio.anytype.domain.auth.interactor.ObserveAccounts
import com.anytypeio.anytype.domain.auth.interactor.RecoverWallet
import com.anytypeio.anytype.domain.auth.interactor.SaveMnemonic
@ -32,7 +31,6 @@ import com.anytypeio.anytype.domain.subscriptions.GlobalSubscriptionManager
import com.anytypeio.anytype.presentation.auth.account.MigrationHelperDelegate
import com.anytypeio.anytype.presentation.extension.proceedWithAccountEvent
import com.anytypeio.anytype.presentation.extension.sendAnalyticsOnboardingLoginEvent
import com.anytypeio.anytype.presentation.splash.SplashViewModel.State
import com.anytypeio.anytype.presentation.util.downloader.UriFileProvider
import javax.inject.Inject
import kotlinx.coroutines.Job
@ -272,7 +270,7 @@ class OnboardingMnemonicLoginViewModel @Inject constructor(
state.value = SetupState.Failed
when (e) {
is AccountMigrationNeededException -> {
proceedWithAccountMigration(id)
state.value = SetupState.Migration.AwaitingStart(account = id)
}
is AccountIsDeletedException -> {
sideEffects.emit(value = SideEffect.Error.AccountDeletedError)
@ -404,6 +402,14 @@ class OnboardingMnemonicLoginViewModel @Inject constructor(
}
}
fun onStartMigrationClicked(account: Id) {
viewModelScope.launch {
if (state.value is SetupState.Migration.AwaitingStart) {
proceedWithAccountMigration(account)
}
}
}
override fun onCleared() {
super.onCleared()
goroutinesJob?.cancel()
@ -428,6 +434,7 @@ class OnboardingMnemonicLoginViewModel @Inject constructor(
data object Abort: SetupState()
sealed class Migration : SetupState() {
abstract val account: Id
data class AwaitingStart(override val account: Id) : Migration()
data class InProgress(override val account: Id): Migration()
data class Failed(
val state: MigrationHelperDelegate.State.Failed,

View file

@ -90,6 +90,14 @@ class SplashViewModel(
}
}
fun onStartMigrationClicked() {
viewModelScope.launch {
if (state.value is State.Migration.AwaitingStart) {
proceedWithAccountMigration()
}
}
}
fun onRetryMigrationClicked() {
viewModelScope.launch {
migrationRetryCount = migrationRetryCount + 1
@ -167,7 +175,6 @@ class SplashViewModel(
state.value = State.Loading
launchAccount(BaseUseCase.None).proceed(
success = { analyticsId ->
state.value = State.Loading
crashReporter.setUser(analyticsId)
updateUserProps(analyticsId)
val props = Props.empty()
@ -179,7 +186,7 @@ class SplashViewModel(
Timber.e(e, "Error while launching account")
when (e) {
is AccountMigrationNeededException -> {
proceedWithAccountMigration()
state.value = State.Migration.AwaitingStart
}
is NeedToUpdateApplicationException -> {
state.value = State.Error(ERROR_NEED_UPDATE)
@ -437,6 +444,7 @@ class SplashViewModel(
data object Success: State()
data class Error(val msg: String): State()
sealed class Migration : State() {
data object AwaitingStart: Migration()
data object InProgress: Migration()
data class Failed(val state: MigrationHelperDelegate.State.Failed) : Migration()
}