diff --git a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt index 57706d70cccf..4e22c9fb049b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt @@ -218,6 +218,13 @@ open class DataViewViewModel @Inject constructor( appLogWrapper.d(AppLog.T.MAIN, "$logTag updateUiState: $state") } + /** + * Removes an item from the local list of items + */ + fun removeItem(id: Long) { + _items.value = items.value.filter { it.id != id } + } + /** * Descendants should override this to perform their specific network request */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt index 5377b14794f7..918a8ec69e33 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt @@ -4,7 +4,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.wordpress.android.R import org.wordpress.android.fluxc.store.AccountStore @@ -62,7 +61,7 @@ class AddSubscribersViewModel @Inject constructor( ) { launch(bgDispatcher) { val result = addSubscribers(emails) - launch(mainDispatcher) { + withContext(mainDispatcher) { if (result.isSuccess) { toastUtilsWrapper.showToast(R.string.subscribers_add_success) onSuccess() @@ -73,41 +72,43 @@ class AddSubscribersViewModel @Inject constructor( } } - private suspend fun addSubscribers(emails: List): Result = withContext(ioDispatcher) { - val params = AddSubscribersParams( - emails = emails - ) + private suspend fun addSubscribers(emails: List) = runCatching { + withContext(ioDispatcher) { + val params = AddSubscribersParams( + emails = emails + ) - _showProgress.value = true - try { - val response = wpComApiClient.request { requestBuilder -> - requestBuilder.subscribers().addSubscribers( - wpComSiteId = siteId().toULong(), - params = params - ) - } + _showProgress.value = true + try { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.subscribers().addSubscribers( + wpComSiteId = siteId().toULong(), + params = params + ) + } - when (response) { - is WpRequestResult.Success -> { - // the backend may return HTTP 200 even when no subscribers were added, so verify there's - // a valid uploadId before assuming success - if (response.response.data.uploadId == 0.toULong()) { - appLogWrapper.d(AppLog.T.MAIN, "No subscribers added") - return@withContext Result.failure(Exception("No subscribers added")) - } else { - appLogWrapper.d(AppLog.T.MAIN, "Successfully added ${emails.size} subscribers") - return@withContext Result.success(true) + when (response) { + is WpRequestResult.Success -> { + // the backend may return HTTP 200 even when no subscribers were added, so verify there's + // a valid uploadId before assuming success + if (response.response.data.uploadId == 0.toULong()) { + appLogWrapper.d(AppLog.T.MAIN, "No subscribers added") + Result.failure(Exception("No subscribers added")) + } else { + appLogWrapper.d(AppLog.T.MAIN, "Successfully added ${emails.size} subscribers") + Result.success(true) + } } - } - else -> { - val error = (response as? WpRequestResult.WpError)?.errorMessage - appLogWrapper.e(AppLog.T.MAIN, "Failed to add subscriber: $response") - return@withContext Result.failure(Exception(error)) + else -> { + val error = (response as? WpRequestResult.WpError)?.errorMessage + appLogWrapper.e(AppLog.T.MAIN, "Failed to add subscriber: $response") + Result.failure(Exception(error)) + } } + } finally { + _showProgress.value = false } - } finally { - _showProgress.value = false } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt index 42a0cd6f8f44..efc7e44e4ffb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt @@ -57,6 +57,7 @@ fun SubscriberDetailScreen( onUrlClick: (String) -> Unit, onEmailClick: (String) -> Unit, onPlanClick: (index: Int) -> Unit, + onDeleteClick: (subscriber: Subscriber) -> Unit, modifier: Modifier = Modifier, subscriberStats: State? = null ) { @@ -92,7 +93,11 @@ fun SubscriberDetailScreen( Spacer(modifier = Modifier.height(32.dp)) - DeleteSubscriberButton() + DeleteSubscriberButton( + onClick = { + onDeleteClick(subscriber) + } + ) } } @@ -346,9 +351,13 @@ private fun DetailRow( } @Composable -private fun DeleteSubscriberButton() { +private fun DeleteSubscriberButton( + onClick: () -> Unit, +) { Button( - onClick = { /* Handle delete action */ }, + onClick = { + onClick() + }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = Color.Transparent, @@ -399,7 +408,8 @@ fun SubscriberDetailScreenPreview() { subscriberStats = remember { mutableStateOf(subscriberStats) }, onUrlClick = {}, onEmailClick = {}, - onPlanClick = {} + onPlanClick = {}, + onDeleteClick = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt index 7c6755b28599..4f50b55a06d1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.subscribers import android.os.Build import android.os.Bundle +import android.widget.Toast import androidx.activity.viewModels import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -17,15 +18,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -40,6 +46,7 @@ class SubscribersActivity : BaseAppCompatActivity() { private val addSubscribersViewModel by viewModels() private lateinit var composeView: ComposeView + private lateinit var navController: NavHostController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -55,6 +62,24 @@ class SubscribersActivity : BaseAppCompatActivity() { } } ) + + lifecycleScope.launch { + viewModel.uiEvent.filterNotNull().collect { event -> + when (event) { + is SubscribersViewModel.UiEvent.ShowDeleteConfirmationDialog -> { + showDeleteConfirmationDialog(event.subscriber, navController) + } + + is SubscribersViewModel.UiEvent.ShowDeleteSuccessDialog -> { + showDeleteSuccessDialog() + } + + is SubscribersViewModel.UiEvent.ShowToast -> { + Toast.makeText(this@SubscribersActivity, event.messageRes, Toast.LENGTH_SHORT).show() + } + } + } + } } private enum class SubscriberScreen { @@ -67,10 +92,10 @@ class SubscribersActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NavigableContent() { - val navController = rememberNavController() + navController = rememberNavController() val listTitle = stringResource(R.string.subscribers) val titleState = remember { mutableStateOf(listTitle) } - val showAddSubscribersButtonState = remember { mutableStateOf(true) } + val showAddSubscribersButtonState = rememberSaveable { mutableStateOf(true) } AppThemeM3 { Scaffold( @@ -240,6 +265,11 @@ class SubscribersActivity : BaseAppCompatActivity() { ) navController.navigate(route = SubscriberScreen.Plan.name) }, + onDeleteClick = { _ -> + viewModel.onDeleteSubscriberClick( + subscriber = subscriber, + ) + }, modifier = modifier, subscriberStats = viewModel.subscriberStats.collectAsState() ) @@ -265,6 +295,39 @@ class SubscribersActivity : BaseAppCompatActivity() { ) } + /** + * The user tapped the "Delete subscriber" button, so show a confirmation dialog + * before telling the view model to actually delete the subscriber + */ + private fun showDeleteConfirmationDialog( + subscriber: Subscriber, + navController: NavHostController + ) { + MaterialAlertDialogBuilder(this).also { builder -> + builder.setTitle(R.string.subscribers_delete_confirmation_title) + builder.setMessage(R.string.subscribers_delete_confirmation_message) + builder.setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteSubscriberConfirmed( + subscriber = subscriber, + onSuccess = { + navController.navigateUp() + } + ) + } + builder.setNegativeButton(R.string.cancel, null) + builder.show() + } + } + + private fun showDeleteSuccessDialog() { + MaterialAlertDialogBuilder(this).also { builder -> + builder.setTitle(R.string.subscribers_delete_success_title) + builder.setMessage(R.string.subscribers_delete_success_message) + builder.setPositiveButton(R.string.ok, null) + builder.show() + } + } + private fun onEmailClick(email: String) { ActivityLauncher.openUrlExternal(this, "mailto:$email") } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt index 66e084d748f0..23b8ba1c822e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt @@ -30,7 +30,7 @@ import javax.inject.Named @HiltViewModel class SubscribersViewModel @Inject constructor( - @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, + @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, private val appLogWrapper: AppLogWrapper, ) : DataViewViewModel( mainDispatcher = mainDispatcher, @@ -44,6 +44,15 @@ class SubscribersViewModel @Inject constructor( @Inject lateinit var dateFormatWrapper: SimpleDateFormatWrapper + sealed class UiEvent { + data class ShowDeleteConfirmationDialog(val subscriber: Subscriber) : UiEvent() + data object ShowDeleteSuccessDialog : UiEvent() + data class ShowToast(val messageRes: Int) : UiEvent() + } + + private val _uiEvent = MutableStateFlow(null) + val uiEvent = _uiEvent + override fun getSupportedFilters(): List { return listOf( DataViewDropdownItem( @@ -175,7 +184,7 @@ class SubscribersViewModel @Inject constructor( ) } - /* + /** * Returns the subscriber with the given ID, or null if not found. Note that this does NOT do a network call, * it simply returns the subscriber from the existing list of items. */ @@ -210,6 +219,39 @@ class SubscribersViewModel @Inject constructor( } } + private suspend fun deleteSubscriber(subscriber: Subscriber) = runCatching { + withContext(ioDispatcher) { + val response = if (subscriber.isEmailSubscriber) { + wpComApiClient.request { requestBuilder -> + requestBuilder.followers().deleteEmailFollower( + wpComSiteId = siteId().toULong(), + subscriptionId = subscriber.subscriptionId + ) + } + } else { + wpComApiClient.request { requestBuilder -> + requestBuilder.followers().deleteFollower( + wpComSiteId = siteId().toULong(), + userId = subscriber.userId + ) + } + } + when (response) { + is WpRequestResult.Success -> { + appLogWrapper.d(AppLog.T.MAIN, "Delete subscriber success") + Result.success(true) + } + + else -> { + val error = (response as? WpRequestResult.WpError)?.errorMessage + appLogWrapper.e(AppLog.T.MAIN, "Delete subscriber failed: $error") + Result.failure(Exception(error)) + } + } + } + } + + /** * Called when an item in the list is clicked. We use this to request stats for the clicked subscriber. */ @@ -225,6 +267,41 @@ class SubscribersViewModel @Inject constructor( } } + /** + * Trigger the delete confirmation dialog when the user taps the delete button for a subscriber + */ + fun onDeleteSubscriberClick(subscriber: Subscriber) { + appLogWrapper.d(AppLog.T.MAIN, "Clicked on delete subscriber ${subscriber.displayNameOrEmail()}") + _uiEvent.value = UiEvent.ShowDeleteConfirmationDialog(subscriber) + clearUiEvent() + } + + /** + * Subscriber deletion has been confirmed by the user so delete the subscriber + */ + fun deleteSubscriberConfirmed(subscriber: Subscriber, onSuccess: () -> Unit) { + launch(ioDispatcher) { + val result = deleteSubscriber(subscriber = subscriber) + + withContext(mainDispatcher) { + if (result.isSuccess) { + // note that it may take a few seconds for the subscriber to actually be deleted, + // which is why we only remove it locally instead of fetching the list again + removeItem(subscriber.userId) + _uiEvent.value = UiEvent.ShowDeleteSuccessDialog + onSuccess() + } else { + _uiEvent.value = UiEvent.ShowToast(R.string.subscribers_delete_failed) + } + clearUiEvent() + } + } + } + + private fun clearUiEvent() { + _uiEvent.value = null + } + companion object { private const val ID_FILTER_EMAIL = 1L private const val ID_FILTER_READER = 2L diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 1da6cfae5906..6e21268c4fc3 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5032,6 +5032,11 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Successfully added subscribers Failed to add subscribers + Delete subscriber + Are you sure you want to delete this subscriber? + Subscriber deleted + The subscriber has been deleted, but it may take a minute for the deletion to take effect + Failed to delete subscriber Application password credentials stored for %1$s diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaa60aaad27d..c07d19b15e4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,7 +103,7 @@ wiremock = '2.26.3' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.1.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-14fe1179d108dc91066776e28ee9690864cab1f1' +wordpress-rs = 'trunk-d6ea38c25176ffa2fa3ce8539ae6b078d58cfdad' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.0'