diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 212a6e0f5f39..479bc3f21d4a 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,7 +2,7 @@ 22.5 ----- - +* [*] Adds a button to enable account closure from the account settings screen [https://github.com/wordpress-mobile/WordPress-Android/pull/18412] 22.4 ----- diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt index 1550cb5839eb..4cc5c18c3a18 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt @@ -363,7 +363,8 @@ class HelpActivity : LocaleAwareActivity() { JETPACK_MIGRATION_HELP("origin:jetpack-migration-help"), JETPACK_INSTALL_FULL_PLUGIN_ONBOARDING("origin:jp-install-full-plugin-overlay"), JETPACK_INSTALL_FULL_PLUGIN_ERROR("origin:jp-install-full-plugin-error"), - JETPACK_REMOTE_INSTALL_PLUGIN_ERROR("origin:jp-remote-install-plugin-error"); + JETPACK_REMOTE_INSTALL_PLUGIN_ERROR("origin:jp-remote-install-plugin-error"), + ACCOUNT_CLOSURE_DIALOG("origin:account-closure-dialog"); override fun toString(): String { return stringValue diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsAnalyticsTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsAnalyticsTracker.kt index 2d790c8c4f1c..85fff677cf5b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsAnalyticsTracker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsAnalyticsTracker.kt @@ -1,6 +1,8 @@ package org.wordpress.android.ui.prefs.accountsettings import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.analytics.AnalyticsTracker.Stat.CLOSED_ACCOUNT +import org.wordpress.android.analytics.AnalyticsTracker.Stat.CLOSE_ACCOUNT_FAILED import org.wordpress.android.analytics.AnalyticsTracker.Stat.SETTINGS_DID_CHANGE import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsEvent.EMAIL_CHANGED import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsEvent.PASSWORD_CHANGED @@ -17,6 +19,7 @@ private const val SOURCE_ACCOUNT_SETTINGS = "account_settings" private const val TRACK_PROPERTY_FIELD_NAME = "field_name" private const val TRACK_PROPERTY_PAGE = "page" private const val TRACK_PROPERTY_PAGE_ACCOUNT_SETTINGS = "account_settings" +private const val KEY_ACCOUNT_CLOSURE_ERROR_CODE = "error_code" enum class AccountSettingsEvent(val trackProperty: String? = null) { EMAIL_CHANGED("email"), @@ -50,4 +53,16 @@ class AccountSettingsAnalyticsTracker @Inject constructor(private val analyticsT props[SOURCE] = SOURCE_ACCOUNT_SETTINGS analyticsTracker.track(stat, props) } + + fun trackAccountClosureFailure(errorCode: String?) { + mutableMapOf().apply { + put(KEY_ACCOUNT_CLOSURE_ERROR_CODE, errorCode ?: "unknown") + }.let { props -> + analyticsTracker.track(CLOSE_ACCOUNT_FAILED, props) + } + } + + fun trackAccountClosureSuccess() { + analyticsTracker.track(CLOSED_ACCOUNT) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsFragment.kt index 8f3995ca627e..550eb0758aa6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsFragment.kt @@ -14,13 +14,21 @@ import android.view.View import android.view.ViewGroup import android.widget.ListView import android.widget.TextView +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.WordPress +import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.accounts.HelpActivity import org.wordpress.android.ui.accounts.signup.BaseUsernameChangerFullScreenDialogFragment +import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.prefs.DetailListPreference import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation @@ -37,10 +45,13 @@ import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsEvent.USERN import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsEvent.USERNAME_CHANGE_SCREEN_DISPLAYED import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsEvent.WEB_ADDRESS_CHANGED import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountSettingsUiState +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.ChangePasswordSettingsUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.EmailSettingsUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.PrimarySiteSettingsUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.UserNameSettingsUiState +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.Companion.AccountClosureAction +import org.wordpress.android.ui.prefs.accountsettings.components.AccountClosureUi import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T.SETTINGS @@ -90,6 +101,7 @@ class AccountSettingsFragment : PreferenceFragmentLifeCycleOwner(), addPreferencesFromResource(R.xml.account_settings) bindPreferences() setUpListeners() + observeAccountClosureEvents() emailPreference.configure( inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, validationType = EMAIL @@ -117,6 +129,30 @@ class AccountSettingsFragment : PreferenceFragmentLifeCycleOwner(), changePasswordPreference.summary = EMPTY_STRING } + private fun observeAccountClosureEvents() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.userActionEvents.collect { handleUserAction(it) } + } + } + lifecycleScope.launch { + // Using `CREATED` state here prevents tracking duplicate events + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.accountClosureUiState.collect { + when (it) { + is AccountClosureUiState.Opened.Error -> { + analyticsTracker.trackAccountClosureFailure(it.errorType.token) + } + is AccountClosureUiState.Opened.Success -> { + analyticsTracker.trackAccountClosureSuccess() + } + else -> {} + } + } + } + } + } + private fun setUpListeners() { usernamePreference.onPreferenceClickListener = this@AccountSettingsFragment primarySitePreference.onPreferenceChangeListener = this@AccountSettingsFragment @@ -153,6 +189,21 @@ class AccountSettingsFragment : PreferenceFragmentLifeCycleOwner(), return coordinatorView } + @Deprecated("Deprecated") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (view.findViewById(android.R.id.list) as? ListView)?.let { listView -> + listView.addFooterView(ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + AccountClosureUi(viewModel) + } + } + }) + } + } + @Deprecated("Deprecated") override fun onStart() { super.onStart() @@ -349,4 +400,26 @@ class AccountSettingsFragment : PreferenceFragmentLifeCycleOwner(), } } } + + private fun handleUserAction(action: AccountClosureAction) { + when (action) { + AccountClosureAction.HELP_VIEWED -> viewHelp() + AccountClosureAction.ACCOUNT_CLOSED -> signOut() + AccountClosureAction.USER_LOGGED_OUT -> { + ActivityLauncher.showMainActivity(context, true) + } + } + } + private fun viewHelp() = ActivityLauncher.viewHelp( + context, + HelpActivity.Origin.ACCOUNT_CLOSURE_DIALOG, + null, + null, + ) + + private fun signOut() { + (activity.application as? WordPress)?.let { + viewModel.signOutWordPress(it) + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModel.kt index bc5b5ac9eb85..743af4cdb045 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModel.kt @@ -5,19 +5,30 @@ import androidx.lifecycle.viewModelScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wordpress.android.R +import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult import org.wordpress.android.fluxc.store.AccountStore.AccountError import org.wordpress.android.fluxc.store.AccountStore.AccountErrorType.SETTINGS_FETCH_GENERIC_ERROR import org.wordpress.android.fluxc.store.AccountStore.AccountErrorType.SETTINGS_FETCH_REAUTHORIZATION_REQUIRED_ERROR import org.wordpress.android.fluxc.store.AccountStore.AccountErrorType.SETTINGS_POST_ERROR import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged +import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.pages.SnackbarMessageHolder +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState.Dismissed +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState.Opened.Error +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState.Opened.Default +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState.Opened.Success +import org.wordpress.android.ui.prefs.accountsettings.usecase.AccountClosureUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.FetchAccountSettingsUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.GetAccountUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.GetSitesUseCase @@ -38,15 +49,21 @@ class AccountSettingsViewModel @Inject constructor( private val resourceProvider: ResourceProvider, networkUtilsWrapper: NetworkUtilsWrapper, @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val fetchAccountSettingsUseCase: FetchAccountSettingsUseCase, private val pushAccountSettingsUseCase: PushAccountSettingsUseCase, private val getAccountUseCase: GetAccountUseCase, private val getSitesUseCase: GetSitesUseCase, - private val optimisticUpdateHandler: AccountSettingsOptimisticUpdateHandler + private val optimisticUpdateHandler: AccountSettingsOptimisticUpdateHandler, + private val accountClosureUseCase: AccountClosureUseCase, ) : ScopedViewModel(mainDispatcher) { private var fetchNewSettingsJob: Job? = null private var _accountSettingsUiState = MutableStateFlow(getAccountSettingsUiState(true)) val accountSettingsUiState: StateFlow = _accountSettingsUiState.asStateFlow() + private var _accountClosureUiState = MutableStateFlow(Dismissed) + val accountClosureUiState: StateFlow = _accountClosureUiState + private var _userActionEvents = MutableSharedFlow() + val userActionEvents: SharedFlow = _userActionEvents init { viewModelScope.launch { @@ -288,8 +305,73 @@ class AccountSettingsViewModel @Inject constructor( val toastMessage: String? ) + sealed class AccountClosureUiState { + object Dismissed: AccountClosureUiState() + + sealed class Opened: AccountClosureUiState() { + data class Default(val username: String?, val isPending: Boolean = false): Opened() + data class Error(val errorType: CloseAccountResult.ErrorType): Opened() + object Success: Opened() + } + } + + fun openAccountClosureDialog() { + launch { + _accountClosureUiState.value = if (getSitesUseCase.getAtomic().isNotEmpty()) { + Error(CloseAccountResult.ErrorType.ATOMIC_SITE) + } else { + Default(username = getAccountUseCase.account.userName) + } + } + } + fun dismissAccountClosureDialog() { + _accountClosureUiState.value = Dismissed + } + + fun closeAccount() { + (accountClosureUiState.value as? Default)?.let { uiState -> + _accountClosureUiState.value = uiState.copy(isPending = true) + + launch { + accountClosureUseCase.closeAccount( + onResult = { + when(it) { + is CloseAccountResult.Success -> { + _accountClosureUiState.value = Success + } + is CloseAccountResult.Failure -> { + _accountClosureUiState.value = Error(it.error.errorType) + } + } + } + ) + } + } + } + + fun signOutWordPress(application: WordPress) { + launch { + withContext(bgDispatcher) { + application.wordPressComSignOut() + userAction(AccountClosureAction.USER_LOGGED_OUT) + } + } + } + + fun userAction(action: AccountClosureAction) { + launch { + _userActionEvents.emit(action) + } + } + override fun onCleared() { pushAccountSettingsUseCase.onCleared() super.onCleared() } + + companion object { + enum class AccountClosureAction { + HELP_VIEWED, ACCOUNT_CLOSED, USER_LOGGED_OUT; + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureDialog.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureDialog.kt new file mode 100644 index 000000000000..3e3013a1d3f4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureDialog.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@Composable +fun AccountClosureDialog( + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val padding = 10.dp + Dialog(onDismissRequest = onDismissRequest) { + Column( + modifier = Modifier + .clip(shape = RoundedCornerShape(padding)) + .background(MaterialTheme.colors.background) + .padding(padding), + content = content, + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureUi.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureUi.kt new file mode 100644 index 000000000000..4a595644280b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/AccountClosureUi.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState.Opened +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.Companion.AccountClosureAction.ACCOUNT_CLOSED +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.Companion.AccountClosureAction.HELP_VIEWED + +@Composable +fun AccountClosureUi(viewModel: AccountSettingsViewModel) { + val uiState = viewModel.accountClosureUiState.collectAsState() + + CloseAccountButton(onClick = { viewModel.openAccountClosureDialog() }) + + (uiState.value as? Opened)?.let { + AccountClosureDialog( + onDismissRequest = { viewModel.dismissAccountClosureDialog() }, + ) { + when(it) { + is Opened.Default -> it.username?.let { currentUsername -> + DialogUi( + currentUsername = currentUsername, + isPending = it.isPending, + onCancel = { viewModel.dismissAccountClosureDialog() }, + onConfirm = { viewModel.closeAccount() }, + ) + } + + is Opened.Error -> DialogErrorUi( + onDismissRequest = { viewModel.dismissAccountClosureDialog() }, + onHelpRequested = { viewModel.userAction(HELP_VIEWED) }, + it.errorType, + ) + is Opened.Success -> DialogSuccessUi( + onDismissRequest = { viewModel.userAction(ACCOUNT_CLOSED) } + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/CloseAccountButton.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/CloseAccountButton.kt new file mode 100644 index 000000000000..998c8d2d1269 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/CloseAccountButton.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun CloseAccountButton(onClick: () -> Unit = {}) = Button( + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.error, + disabledBackgroundColor = Color.Transparent, + disabledContentColor = MaterialTheme.colors.error, + ), + modifier = Modifier + .fillMaxWidth(), + onClick = onClick, +) { + Text( + text = stringResource(R.string.close_account), + modifier = Modifier + .padding(10.dp), + ) +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewCloseAccountButton() { + AppTheme { + CloseAccountButton() + } +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogErrorUi.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogErrorUi.kt new file mode 100644 index 000000000000..4db61e0eeccf --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogErrorUi.kt @@ -0,0 +1,87 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.UNAUTHORIZED +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.ATOMIC_SITE +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.CHARGEBACKED_SITE +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.ACTIVE_SUBSCRIPTIONS +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.ACTIVE_MEMBERSHIPS +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.INVALID_TOKEN +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType.UNKNOWN +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun DialogErrorUi( + onDismissRequest: () -> Unit, + onHelpRequested: () -> Unit, + errorType: ErrorType, +) { + val padding = 10.dp + val messageId = when(errorType) { + UNAUTHORIZED -> R.string.account_closure_dialog_error_unauthorized + ATOMIC_SITE -> R.string.account_closure_dialog_error_atomic_site + CHARGEBACKED_SITE -> R.string.account_closure_dialog_error_chargebacked_site + ACTIVE_SUBSCRIPTIONS -> R.string.account_closure_dialog_error_active_subscriptions + ACTIVE_MEMBERSHIPS -> R.string.account_closure_dialog_error_active_memberships + INVALID_TOKEN, UNKNOWN -> R.string.account_closure_dialog_error_unknown + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = padding), + textAlign = TextAlign.Center, + text = stringResource(R.string.account_closure_dialog_error_title), + fontWeight = FontWeight.Bold, + ) + Text(stringResource(messageId)) + Spacer(Modifier.size(padding)) + FlatOutlinedButton( + text = stringResource(R.string.dismiss), + onClick = onDismissRequest, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.primary, + backgroundColor = Color.Transparent, + ), + ) + Spacer(Modifier.size(padding)) + FlatButton( + text = stringResource(R.string.contact_support), + onClick = onHelpRequested, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewDialogErrorUi() { + AppTheme { + AccountClosureDialog( + onDismissRequest = {}, + ) { + DialogErrorUi( + onDismissRequest = {}, + onHelpRequested = {}, + errorType = ATOMIC_SITE, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogSuccessUi.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogSuccessUi.kt new file mode 100644 index 000000000000..4ad07c9635b5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogSuccessUi.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun DialogSuccessUi( + onDismissRequest: () -> Unit, +) { + val padding = 10.dp + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = padding), + textAlign = TextAlign.Center, + text = stringResource(R.string.account_closure_dialog_success_message), + fontWeight = FontWeight.Bold, + ) + FlatOutlinedButton( + text = stringResource(R.string.ok), + onClick = onDismissRequest, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.primary, + backgroundColor = Color.Transparent, + ), + ) +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewDialogSuccessUi() { + AppTheme { + AccountClosureDialog( + onDismissRequest = {}, + ) { + DialogSuccessUi( + onDismissRequest = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogUi.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogUi.kt new file mode 100644 index 000000000000..d10ce9ca8d1a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/DialogUi.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun DialogUi( + currentUsername: String, + isPending: Boolean, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + var username by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val padding = 10.dp + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = padding), + textAlign = TextAlign.Center, + text = stringResource(R.string.account_closure_dialog_title), + fontWeight = FontWeight.Bold, + ) + Text(stringResource(R.string.account_closure_dialog_message)) + TextField( + modifier = Modifier + .padding(vertical = padding) + .fillMaxWidth() + .focusRequester(focusRequester), + value = username, + onValueChange = { username = it }, + ) + Row( + modifier = Modifier + .padding(vertical = padding) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + ) { + FlatOutlinedButton( + text = stringResource(R.string.cancel), + onClick = onCancel, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.onSurface, + backgroundColor = Color.Transparent, + disabledContentColor = MaterialTheme.colors.onSurface, + ), + enabled = !isPending, + ) + Spacer(Modifier.size(padding)) + FlatOutlinedButton( + text = stringResource(R.string.confirm), + modifier = Modifier.weight(1f), + enabled = username.isNotEmpty() && username == currentUsername, + isPending = isPending, + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.error, + backgroundColor = Color.Transparent, + ), + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewDialogUi() { + AppTheme { + AccountClosureDialog( + onDismissRequest = {}, + ) { + DialogUi( + currentUsername = "previewUser", + isPending = false, + onConfirm = {}, + onCancel = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/FlatButtons.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/FlatButtons.kt new file mode 100644 index 000000000000..654e91273f1c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/components/FlatButtons.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.ui.prefs.accountsettings.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.wordpress.android.ui.compose.theme.AppColor + +@Composable +fun FlatButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors( + contentColor = AppColor.White, + ), + enabled: Boolean = true, +) = Button( + modifier = modifier, + onClick = onClick, + colors = colors, + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + enabled = enabled, +) { + Text(text) +} + +@Composable +fun FlatOutlinedButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors(), + enabled: Boolean = true, + isPending: Boolean = false, +) = OutlinedButton( + modifier = modifier, + onClick = onClick, + colors = colors, + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + enabled = enabled && !isPending, +) { + if (isPending) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(20.dp), + ) + } else { + Text(text) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/AccountClosureUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/AccountClosureUseCase.kt new file mode 100644 index 000000000000..a1a93f413a9a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/AccountClosureUseCase.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.ui.prefs.accountsettings.usecase + +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult +import org.wordpress.android.fluxc.network.rest.wpcom.account.closeAccount +import javax.inject.Inject + +class AccountClosureUseCase @Inject constructor( + private val accountRestClient: AccountRestClient, +) { + fun closeAccount(onResult: (CloseAccountResult) -> Unit) = accountRestClient.closeAccount(onResult) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/GetSitesUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/GetSitesUseCase.kt index e038552d89b1..25bfd0033554 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/GetSitesUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/accountsettings/usecase/GetSitesUseCase.kt @@ -15,4 +15,6 @@ class GetSitesUseCase @Inject constructor( suspend fun get(): List = withContext(ioDispatcher) { siteStore.sitesAccessedViaWPComRest } + + suspend fun getAtomic() = get().filter { it.isWPComAtomic } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index d94b5bf7442e..d1e25ae449a7 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Update Now Status & Visibility Free + Dismiss Not now Skip @@ -2755,6 +2756,17 @@ Export email sent! Export your content Your posts, pages, and settings will be emailed to you at %s. + Close Account + To confirm, please re-enter your username before closing. + Confirm Close Account… + Couldn\'t close account automatically" + You\'re not authorized to close the account. + This user account cannot be closed immediately because it has active purchases. Please contact our support team to finish deleting the account. + This user account cannot be closed if there are unresolved chargebacks. + This user account cannot be closed while it has active subscriptions. + This user account cannot be closed while it has active purchases. + An error occurred while closing account. + Account closed. Plan diff --git a/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModelTest.kt index 8c148c9aa906..f10122ea79c7 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/AccountSettingsViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.prefs.accountsettings +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList @@ -8,17 +9,21 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mock +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R.string import org.wordpress.android.fluxc.model.AccountModel import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult import org.wordpress.android.fluxc.store.AccountStore.AccountError import org.wordpress.android.fluxc.store.AccountStore.AccountErrorType.GENERIC_ERROR import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged +import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountClosureUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.AccountSettingsUiState import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel.SiteUiModel +import org.wordpress.android.ui.prefs.accountsettings.usecase.AccountClosureUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.FetchAccountSettingsUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.GetAccountUseCase import org.wordpress.android.ui.prefs.accountsettings.usecase.GetSitesUseCase @@ -54,6 +59,9 @@ class AccountSettingsViewModelTest : BaseUnitTest() { @Mock private lateinit var account: AccountModel + @Mock + lateinit var accountClosureUseCase: AccountClosureUseCase + private val siteViewModels = mutableListOf().apply { add(SiteUiModel("HappyDay", 1L, "http://happyday.wordpress.com")) add(SiteUiModel("WonderLand", 2L, "http://wonderland.wordpress.com")) @@ -80,7 +88,7 @@ class AccountSettingsViewModelTest : BaseUnitTest() { } @Test - fun `The initial primarysite is shown from cached account settings`() = test { + fun `The initial primary site is shown from cached account settings`() = test { uiState.primarySiteSettingsUiState.primarySite?.siteId?.let { assertThat(it).isEqualTo(getAccountUseCase.account.primarySiteId) } @@ -407,6 +415,31 @@ class AccountSettingsViewModelTest : BaseUnitTest() { .isEqualTo(false) } + @Test + fun `When account closure succeeds, then the closure dialog should be in the success state`() = test { + mockAccountClosureWithResult(CloseAccountResult.Success) + viewModel.closeAccount() + assertTrue(viewModel.accountClosureUiState.value is AccountClosureUiState.Opened.Success) + } + + @Test + fun `When account closure fails, then the closure dialog should be in the error state`() = test { + mockAccountClosureWithResult(CloseAccountResult.Failure(CloseAccountResult.Error( + CloseAccountResult.ErrorType.UNKNOWN, + "unknown", + ))) + viewModel.closeAccount() + assertTrue(viewModel.accountClosureUiState.value is AccountClosureUiState.Opened.Error) + } + + @Test + fun `When there is an Atomic site, then the closure dialog should open in the error state`() = test { + val mockAtomicSite: SiteModel = mock() + whenever(getSitesUseCase.getAtomic()).thenReturn(listOf(mockAtomicSite)) + viewModel.openAccountClosureDialog() + assertTrue(viewModel.accountClosureUiState.value is AccountClosureUiState.Opened.Error) + } + // Helper Methods private fun testUiStateChanges( block: suspend CoroutineScope.() -> T @@ -437,14 +470,25 @@ class AccountSettingsViewModelTest : BaseUnitTest() { resourceProvider, networkUtilsWrapper, testDispatcher(), + testDispatcher(), fetchAccountSettingsUseCase, pushAccountSettingsUseCase, getAccountUseCase, getSitesUseCase, - optimisticUpdateHandler + optimisticUpdateHandler, + accountClosureUseCase, ) } + private suspend fun mockAccountClosureWithResult(result: CloseAccountResult) { + whenever(getSitesUseCase.getAtomic()).thenReturn(emptyList()) + whenever(accountClosureUseCase.closeAccount(any())).thenAnswer { + val completion = it.getArgument<((CloseAccountResult) -> Unit)>(0) + completion.invoke(result) + } + viewModel.openAccountClosureDialog() + } + private suspend fun mockSites(siteViewModels: List) { val sites = siteViewModels.map { SiteModel().apply { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/GetSitesUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/GetSitesUseCaseTest.kt new file mode 100644 index 000000000000..7075baa7da40 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/prefs/accountsettings/GetSitesUseCaseTest.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.ui.prefs.accountsettings + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.ui.prefs.accountsettings.usecase.GetSitesUseCase + +@ExperimentalCoroutinesApi +class GetSitesUseCaseTest: BaseUnitTest() { + private lateinit var useCase: GetSitesUseCase + + @Mock + private lateinit var siteStore: SiteStore + + val mockAtomicSite: SiteModel = mock() + val mockNonAtomicSite: SiteModel = mock() + + @Before + fun setUp() = test { + useCase = GetSitesUseCase( + testDispatcher(), + siteStore, + ) + whenever(mockAtomicSite.isWPComAtomic).thenReturn(true) + whenever(mockNonAtomicSite.isWPComAtomic).thenReturn(false) + whenever(siteStore.sitesAccessedViaWPComRest).thenReturn(listOf( + mockAtomicSite, + mockNonAtomicSite, + )) + } + + @Test + fun `getAtomic filters for Atomic sites`() { + test { + val atomicSites = useCase.getAtomic() + assertThat(atomicSites.size).isEqualTo(1) + assertThat(atomicSites.first()).isEqualTo(mockAtomicSite) + } + } +} diff --git a/build.gradle b/build.gradle index cc348fa8f3d8..a14832568cc9 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,7 @@ ext { automatticTracksVersion = '2.2.0' gutenbergMobileVersion = 'v1.95.0' wordPressAztecVersion = 'v1.6.3' - wordPressFluxCVersion = 'trunk-eea5d065c93d9ca6680c28189750e92a6f05f8ac' + wordPressFluxCVersion = 'trunk-3fe318a6de3463bf2444b0d798067546e9a18db0' wordPressLoginVersion = '1.3.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.6.1' diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index 6cb7a07ace86..8b43941f567b 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -377,6 +377,8 @@ public enum Stat { // This stat is part of a funnel that provides critical information. Before // making ANY modification to this stat please refer to: p4qSXL-35X-p2 CREATED_ACCOUNT, + CLOSE_ACCOUNT_FAILED, + CLOSED_ACCOUNT, ACCOUNT_LOGOUT, SHARED_ITEM, SHARED_ITEM_READER, diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java index 23eb35ada3a5..555f73d1df5f 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java @@ -1037,6 +1037,10 @@ public static String getEventNameForStat(AnalyticsTracker.Stat stat) { // This stat is part of a funnel that provides critical information. Before // making ANY modification to this stat please refer to: p4qSXL-35X-p2 return "account_created"; + case CLOSE_ACCOUNT_FAILED: + return "close_account_failed"; + case CLOSED_ACCOUNT: + return "closed_account"; case SHARED_ITEM: return "item_shared"; case SHARED_ITEM_READER: