diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt index c4218f09e1ac..21f143d13c28 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt @@ -41,16 +41,25 @@ class CommentsRsListActivity : BaseAppCompatActivity() { setContent { val tabStates by viewModel.tabStates.collectAsState() + val selectedIds by viewModel.selectedIds.collectAsState() + val confirmation by viewModel.pendingConfirmation.collectAsState() AppThemeM3 { CommentsRsListScreen( tabStates = tabStates, + selectedIds = selectedIds, + pendingConfirmation = confirmation, + onDismissConfirmation = viewModel::onDismissPendingAction, snackbarMessages = viewModel.snackbarMessages, onInitTab = viewModel::initTab, onTabChanged = viewModel::onTabChanged, onRefreshTab = { tab -> viewModel.refreshTab(tab, isUserRefresh = true) }, onLoadMore = viewModel::loadMore, onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, - onCommentClick = viewModel::onCommentClick + onCommentClick = viewModel::onCommentClick, + onCommentLongClick = viewModel::onCommentLongClick, + onClearSelection = viewModel::onClearSelection, + onBatchAction = viewModel::onBatchAction, + onConfirmPendingAction = viewModel::onConfirmPendingAction ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt index 758c456cfd56..bcbab8fd4dce 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt @@ -1,5 +1,8 @@ package org.wordpress.android.ui.commentsrs +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.wordpress.android.R import org.wordpress.android.fluxc.model.CommentStatus /** One comment row. */ @@ -25,3 +28,110 @@ data class CommentsTabUiState( val canLoadMore: Boolean = false, val error: String? = null ) + +/** A destructive batch [action] awaiting user confirmation, to be applied to [commentIds]. */ +data class PendingConfirmation(val action: CommentsRsBatchAction, val commentIds: List) + +/** + * Confirmation-dialog copy for a destructive batch action. [messagePluralResId] is shown when more + * than one comment is selected, otherwise [messageResId]. + */ +data class ConfirmationCopy( + @StringRes val titleResId: Int, + @StringRes val confirmButtonResId: Int, + @StringRes val messageResId: Int, + @StringRes val messagePluralResId: Int = messageResId +) + +/** + * Batch moderation actions offered in selection mode. Each maps to a target [CommentStatus] + * applied to every selected comment (except [DELETE], which deletes permanently); [labelResId] + * doubles as the icon's content description. Mirroring the legacy list's action mode, only the + * [showAsIcon] actions (approve/unapprove) appear as top-bar icon buttons; the rest fall into the + * overflow menu. An action with [confirmation] copy is destructive: it asks before running and is + * tinted accordingly. + */ +enum class CommentsRsBatchAction( + @StringRes val labelResId: Int, + @DrawableRes val iconResId: Int, + val targetStatus: CommentStatus, + val showAsIcon: Boolean = false, + val confirmation: ConfirmationCopy? = null +) { + APPROVE( + R.string.mnu_comment_approve, + R.drawable.ic_thumbs_up_white_24dp, + CommentStatus.APPROVED, + showAsIcon = true + ), + UNAPPROVE( + R.string.mnu_comment_unapprove, + R.drawable.ic_thumbs_down_white_24dp, + CommentStatus.UNAPPROVED, + showAsIcon = true + ), + SPAM(R.string.mnu_comment_spam, R.drawable.ic_spam_white_24dp, CommentStatus.SPAM), + NOT_SPAM(R.string.mnu_comment_unspam, R.drawable.ic_spam_white_24dp, CommentStatus.APPROVED), + TRASH( + R.string.mnu_comment_trash, + R.drawable.ic_trash_white_24dp, + CommentStatus.TRASH, + confirmation = ConfirmationCopy( + titleResId = R.string.trash, + confirmButtonResId = R.string.dlg_confirm_action_trash, + messageResId = R.string.dlg_confirm_trash_comments + ) + ), + UNTRASH(R.string.mnu_comment_untrash, R.drawable.ic_trash_white_24dp, CommentStatus.APPROVED), + DELETE( + R.string.mnu_comment_delete_permanently, + R.drawable.ic_trash_white_24dp, + CommentStatus.DELETED, + confirmation = ConfirmationCopy( + titleResId = R.string.delete, + confirmButtonResId = R.string.delete, + messageResId = R.string.dlg_sure_to_delete_comment, + messagePluralResId = R.string.dlg_sure_to_delete_comments + ) + ) +} + +/** The batch actions offered for a tab's selection, mirroring the legacy action mode. */ +internal fun CommentsRsListTab.batchActions(): List = when (this) { + CommentsRsListTab.ALL -> listOf( + CommentsRsBatchAction.APPROVE, + CommentsRsBatchAction.UNAPPROVE, + CommentsRsBatchAction.SPAM, + CommentsRsBatchAction.TRASH + ) + CommentsRsListTab.PENDING -> listOf( + CommentsRsBatchAction.APPROVE, + CommentsRsBatchAction.SPAM, + CommentsRsBatchAction.TRASH + ) + CommentsRsListTab.APPROVED -> listOf( + CommentsRsBatchAction.UNAPPROVE, + CommentsRsBatchAction.SPAM, + CommentsRsBatchAction.TRASH + ) + CommentsRsListTab.SPAM -> listOf( + CommentsRsBatchAction.NOT_SPAM, + CommentsRsBatchAction.TRASH + ) + CommentsRsListTab.TRASHED -> listOf( + CommentsRsBatchAction.UNTRASH, + CommentsRsBatchAction.DELETE + ) +} + +/** + * Whether [this] action can act on a selection whose comments have [selectedStatuses]. Approve and + * unapprove are disabled when they would be a no-op — nothing unapproved to approve, or nothing + * approved to unapprove; every other action is always enabled. + */ +internal fun CommentsRsBatchAction.isEnabledFor(selectedStatuses: Set): Boolean = + when (this) { + CommentsRsBatchAction.APPROVE -> CommentStatus.UNAPPROVED in selectedStatuses + CommentsRsBatchAction.UNAPPROVE -> CommentStatus.APPROVED in selectedStatuses + else -> true + } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt index 5a52702b0a06..e8ee606a9dac 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt @@ -6,20 +6,25 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.fluxc.model.CommentStatus import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.ui.comments.unified.CommentsRsDataSource import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsCommentsPageResult +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsResult import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.postsrs.PostRsErrorUtils import org.wordpress.android.ui.postsrs.SnackbarMessage @@ -58,6 +63,13 @@ class CommentsRsListViewModel @Inject constructor( private val _snackbarMessages = Channel(Channel.BUFFERED) val snackbarMessages = _snackbarMessages.receiveAsFlow() + // Selection mode is active while this is non-empty. + private val _selectedIds = MutableStateFlow>(emptySet()) + val selectedIds: StateFlow> = _selectedIds.asStateFlow() + + private val _pendingConfirmation = MutableStateFlow(null) + val pendingConfirmation: StateFlow = _pendingConfirmation.asStateFlow() + // Pagination cursors: the next-page params returned with each fetched page, per tab. private val nextPageParams = mutableMapOf() @@ -96,8 +108,8 @@ class CommentsRsListViewModel @Inject constructor( /** * Re-fetches the first page for [tab]. The pull-to-refresh indicator is only shown for a - * user-initiated refresh; silent refreshes (returning from the detail) keep the current - * list on screen until the new page arrives. + * user-initiated refresh; silent refreshes (after moderation, returning from the detail) + * keep the current list on screen until the new page arrives. */ @MainThread fun refreshTab(tab: CommentsRsListTab, isUserRefresh: Boolean = false) { @@ -176,18 +188,122 @@ class CommentsRsListViewModel @Inject constructor( } } - /** Opens the rs comment detail for the tapped row. */ + /** Opens the rs comment detail for the tapped row, or toggles it while selecting. */ @MainThread fun onCommentClick(remoteCommentId: Long) { - _events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId)) + if (_selectedIds.value.isNotEmpty()) { + toggleSelection(remoteCommentId) + } else { + _events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId)) + } + } + + /** Enters selection mode with the long-pressed row selected. */ + @MainThread + fun onCommentLongClick(remoteCommentId: Long) { + toggleSelection(remoteCommentId) + } + + @MainThread + fun onClearSelection() { + _selectedIds.value = emptySet() + } + + /** + * Runs [action] on the selected comments, first asking for confirmation for the destructive + * ones (trash, delete) like the legacy list. + */ + @MainThread + fun onBatchAction(action: CommentsRsBatchAction, tab: CommentsRsListTab) { + val ids = _selectedIds.value.toList() + if (ids.isEmpty()) return + if (action.confirmation != null) { + _pendingConfirmation.value = PendingConfirmation(action, ids) + } else { + performBatchModeration(ids, action.targetStatus, tab) + } + } + + @MainThread + fun onConfirmPendingAction(tab: CommentsRsListTab) { + _pendingConfirmation.value?.let { performBatchModeration(it.commentIds, it.action.targetStatus, tab) } + _pendingConfirmation.value = null + } + + @MainThread + fun onDismissPendingAction() { + _pendingConfirmation.value = null + } + + private fun toggleSelection(remoteCommentId: Long) { + _selectedIds.update { ids -> + if (remoteCommentId in ids) ids - remoteCommentId else ids + remoteCommentId + } + } + + /** + * Applies [newStatus] to every comment in [ids] in parallel, then refreshes all initialized + * tabs (moderated comments move between tabs). Failures are aggregated into one snackbar. + */ + private fun performBatchModeration(ids: List, newStatus: CommentStatus, tab: CommentsRsListTab) { + if (!checkNetwork()) return + trackBatchModeration(newStatus) + onClearSelection() + updateTabUiState(tab) { copy(isRefreshing = true) } + viewModelScope.launch { + val results = withContext(bgDispatcher) { + ids.map { commentId -> + async { + if (newStatus == CommentStatus.DELETED) { + commentsRsDataSource.delete(site, commentId) + } else { + commentsRsDataSource.updateStatus(site, commentId, newStatus) + } + } + }.awaitAll() + } + val failures = results.count { it is RsResult.Error } + if (failures > 0) { + _snackbarMessages.trySend( + SnackbarMessage( + resourceProvider.getString(R.string.comments_rs_moderation_failed, failures, ids.size) + ) + ) + } + refreshAllTabs() + } + } + + private fun trackBatchModeration(newStatus: CommentStatus) { + val stat = when (newStatus) { + CommentStatus.APPROVED -> Stat.COMMENT_BATCH_APPROVED + CommentStatus.UNAPPROVED -> Stat.COMMENT_BATCH_UNAPPROVED + CommentStatus.SPAM -> Stat.COMMENT_BATCH_SPAMMED + CommentStatus.TRASH -> Stat.COMMENT_BATCH_TRASHED + CommentStatus.DELETED -> Stat.COMMENT_BATCH_DELETED + else -> return + } + analyticsTracker.track(stat) + } + + private fun checkNetwork(): Boolean { + if (!networkUtilsWrapper.isNetworkAvailable()) { + _snackbarMessages.trySend( + SnackbarMessage(resourceProvider.getString(R.string.no_network_message)) + ) + return false + } + return true } /** - * Tracks the tab the pager settled on — the same COMMENT_FILTER_CHANGED event (including the - * initial selection) the legacy list emits, so filter metrics stay comparable. + * The pager settled on a new tab: clears any active selection (like the legacy action mode) + * and tracks the same COMMENT_FILTER_CHANGED event (including the initial selection) the + * legacy list emits, so filter metrics stay comparable. */ @MainThread fun onTabChanged(tab: CommentsRsListTab) { + onClearSelection() if (tab == lastTrackedTab) return lastTrackedTab = tab analyticsTracker.track( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt index 860c8ebaf760..d97f42a47455 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt @@ -1,7 +1,10 @@ package org.wordpress.android.ui.commentsrs.screens +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -14,8 +17,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Person import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,17 +43,26 @@ import org.wordpress.android.ui.commentsrs.CommentRsUiModel private val AVATAR_SIZE = 40.dp private val PENDING_INDICATOR_WIDTH = 4.dp +@OptIn(ExperimentalFoundationApi::class) @Composable fun CommentsRsListItem( comment: CommentRsUiModel, + isSelected: Boolean, onClick: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier ) { Column( modifier = modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .clickable(onClick = onClick) + .background( + if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) ) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { // The pending indicator: a colored bar on the row's leading edge, like the legacy @@ -66,7 +80,9 @@ fun CommentsRsListItem( ) ) Row(modifier = Modifier.padding(start = 12.dp, top = 16.dp, end = 16.dp, bottom = 16.dp)) { - CommentAvatar(comment = comment) + // Tapping the avatar toggles selection (the discoverable path into selection + // mode); long-press anywhere on the row does the same. + CommentAvatar(comment = comment, isSelected = isSelected, onClick = onLongClick) Column( modifier = Modifier .padding(start = 16.dp) @@ -102,18 +118,33 @@ fun CommentsRsListItem( } @Composable -private fun CommentAvatar(comment: CommentRsUiModel) { - val fallback = rememberVectorPainter(Icons.Filled.Person) - AsyncImage( - model = comment.avatarUrl.ifBlank { null }, - contentDescription = null, - contentScale = ContentScale.Crop, - fallback = fallback, - error = fallback, - modifier = Modifier - .size(AVATAR_SIZE) - .clip(CircleShape) - ) +private fun CommentAvatar(comment: CommentRsUiModel, isSelected: Boolean, onClick: () -> Unit) { + Crossfade(targetState = isSelected, label = "avatar") { selected -> + if (selected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.comment_checkmark_desc), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(AVATAR_SIZE) + .clip(CircleShape) + .clickable(onClick = onClick) + ) + } else { + val fallback = rememberVectorPainter(Icons.Filled.Person) + AsyncImage( + model = comment.avatarUrl.ifBlank { null }, + contentDescription = null, + contentScale = ContentScale.Crop, + fallback = fallback, + error = fallback, + modifier = Modifier + .size(AVATAR_SIZE) + .clip(CircleShape) + .clickable(onClick = onClick) + ) + } + } } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt index 4e53ea7f3f96..4a03ad446604 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt @@ -1,12 +1,21 @@ package org.wordpress.android.ui.commentsrs.screens +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -18,38 +27,80 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.wordpress.android.R +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.ui.commentsrs.CommentsRsBatchAction import org.wordpress.android.ui.commentsrs.CommentsRsListTab import org.wordpress.android.ui.commentsrs.CommentsTabUiState +import org.wordpress.android.ui.commentsrs.PendingConfirmation +import org.wordpress.android.ui.commentsrs.batchActions +import org.wordpress.android.ui.commentsrs.isEnabledFor import org.wordpress.android.ui.postsrs.SnackbarMessage +// Material's disabled-content alpha, used to dim batch-action icons that can't apply to the +// current selection while keeping them visible. +private const val DISABLED_ICON_ALPHA = 0.38f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentsRsListScreen( tabStates: Map, + selectedIds: Set, + pendingConfirmation: PendingConfirmation?, + onDismissConfirmation: () -> Unit, snackbarMessages: Flow, onInitTab: (CommentsRsListTab) -> Unit, onTabChanged: (CommentsRsListTab) -> Unit, onRefreshTab: (CommentsRsListTab) -> Unit, onLoadMore: (CommentsRsListTab) -> Unit, onNavigateBack: () -> Unit, - onCommentClick: (Long) -> Unit + onCommentClick: (Long) -> Unit, + onCommentLongClick: (Long) -> Unit, + onClearSelection: () -> Unit, + onBatchAction: (CommentsRsBatchAction, CommentsRsListTab) -> Unit, + onConfirmPendingAction: (CommentsRsListTab) -> Unit ) { val tabs = CommentsRsListTab.entries val pagerState = rememberPagerState(pageCount = { tabs.size }) val coroutineScope = rememberCoroutineScope() + // Hoisted so a re-tap on the active tab can scroll its list back to the top. + val listStates = remember { tabs.associateWith { LazyListState() } } + val activeTab = tabs[pagerState.settledPage] val snackbarHostState = remember { SnackbarHostState() } + // Statuses of the selected comments that live on the active tab. This is empty during a tab + // swipe (the selection still belongs to the previous tab and is about to be cleared), so gating + // the contextual bar on it keeps it from flashing the next tab's actions mid-transition. + val selectedStatuses = tabStates[activeTab]?.comments + .orEmpty() + .filter { it.remoteCommentId in selectedIds } + .map { it.status } + .toSet() + val isSelectionActive = selectedStatuses.isNotEmpty() + + // Like the legacy action mode: the first back press dismisses the selection, the next one + // leaves the screen. Enabled on selectedIds (not selectedStatuses) so any live selection is + // cleared, including during the brief tab-swipe window before it clears itself. + BackHandler(enabled = selectedIds.isNotEmpty()) { + onClearSelection() + } LaunchedEffect(snackbarMessages) { snackbarMessages.collect { msg -> @@ -66,17 +117,29 @@ fun CommentsRsListScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - title = { Text(text = stringResource(R.string.comments)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - } + AnimatedContent(targetState = isSelectionActive, label = "topBar") { selectionActive -> + if (selectionActive) { + SelectionTopBar( + selectedCount = selectedIds.size, + actions = activeTab.batchActions(), + selectedStatuses = selectedStatuses, + onClearSelection = onClearSelection, + onBatchAction = { action -> onBatchAction(action, activeTab) } + ) + } else { + TopAppBar( + title = { Text(text = stringResource(R.string.comments)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) } - ) + } } ) { contentPadding -> Column(modifier = Modifier.fillMaxSize().padding(contentPadding)) { @@ -88,7 +151,14 @@ fun CommentsRsListScreen( Tab( selected = pagerState.settledPage == index, onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } + coroutineScope.launch { + if (pagerState.settledPage == index) { + // Re-tapping the active tab scrolls its list back to the top. + listStates.getValue(tab).animateScrollToItem(0) + } else { + pagerState.animateScrollToPage(index) + } + } }, unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, text = { Text(text = stringResource(tab.labelResId)) } @@ -113,11 +183,157 @@ fun CommentsRsListScreen( CommentsRsTabListScreen( state = tabState, emptyMessageResId = tab.emptyMessageResId, + selectedIds = selectedIds, + listState = listStates.getValue(tab), onRefresh = { onRefreshTab(tab) }, onLoadMore = { onLoadMore(tab) }, - onCommentClick = onCommentClick + onCommentClick = onCommentClick, + onCommentLongClick = onCommentLongClick ) } } } + + BatchConfirmationDialogs( + pending = pendingConfirmation, + activeTab = activeTab, + onConfirm = onConfirmPendingAction, + onDismiss = onDismissConfirmation + ) +} + +@Composable +private fun BatchConfirmationDialogs( + pending: PendingConfirmation?, + activeTab: CommentsRsListTab, + onConfirm: (CommentsRsListTab) -> Unit, + onDismiss: () -> Unit +) { + // Every action that reaches confirmation carries its copy (see onBatchAction), so any + // destructive action renders a dialog rather than silently stranding the selection. + val copy = pending?.action?.confirmation ?: return + val messageResId = if (pending.commentIds.size > 1) copy.messagePluralResId else copy.messageResId + ConfirmationDialog( + titleResId = copy.titleResId, + message = stringResource(messageResId), + confirmTextResId = copy.confirmButtonResId, + isDestructive = true, + onConfirm = { onConfirm(activeTab) }, + onDismiss = onDismiss + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionTopBar( + selectedCount: Int, + actions: List, + selectedStatuses: Set, + onClearSelection: () -> Unit, + onBatchAction: (CommentsRsBatchAction) -> Unit +) { + // Like the legacy action mode, approve/unapprove sit in the bar as icons and everything else + // falls into the overflow menu, keeping the selection count on a single line. + val (iconActions, menuActions) = actions.partition { it.showAsIcon } + TopAppBar( + // A distinct container color so selection mode visibly reads as a mode change. + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + title = { Text(text = stringResource(R.string.cab_selected, selectedCount)) }, + navigationIcon = { + IconButton(onClick = onClearSelection) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.clear) + ) + } + }, + actions = { + iconActions.forEach { action -> + // An action that would be a no-op for the selection (e.g. approve when nothing is + // unapproved) stays visible but disabled. + val enabled = action.isEnabledFor(selectedStatuses) + IconButton(onClick = { onBatchAction(action) }, enabled = enabled) { + Icon( + painter = painterResource(action.iconResId), + contentDescription = stringResource(action.labelResId), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) 1f else DISABLED_ICON_ALPHA + ) + ) + } + } + if (menuActions.isNotEmpty()) { + BatchActionsOverflowMenu(actions = menuActions, onBatchAction = onBatchAction) + } + } + ) +} + +@Composable +private fun BatchActionsOverflowMenu( + actions: List, + onBatchAction: (CommentsRsBatchAction) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.more)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + val color = if (action.confirmation != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + DropdownMenuItem( + text = { Text(text = stringResource(action.labelResId), color = color) }, + leadingIcon = { + Icon( + painter = painterResource(action.iconResId), + contentDescription = null, + tint = color + ) + }, + onClick = { + expanded = false + onBatchAction(action) + } + ) + } + } +} + +@Composable +private fun ConfirmationDialog( + @StringRes titleResId: Int, + message: String, + @StringRes confirmTextResId: Int, + isDestructive: Boolean = false, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleResId)) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + stringResource(confirmTextResId), + color = if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + } + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt index ea83ebaf4806..ee6a4f11c95d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -44,9 +44,12 @@ import org.wordpress.android.ui.compose.components.ShimmerBox fun CommentsRsTabListScreen( state: CommentsTabUiState, emptyMessageResId: Int, + selectedIds: Set, + listState: LazyListState, onRefresh: () -> Unit, onLoadMore: () -> Unit, onCommentClick: (Long) -> Unit, + onCommentLongClick: (Long) -> Unit, modifier: Modifier = Modifier ) { val pullToRefreshState = rememberPullToRefreshState() @@ -76,10 +79,13 @@ fun CommentsRsTabListScreen( ) else -> CommentListContent( comments = state.comments, + selectedIds = selectedIds, + listState = listState, isLoadingMore = state.isLoadingMore, canLoadMore = state.canLoadMore, onLoadMore = onLoadMore, - onCommentClick = onCommentClick + onCommentClick = onCommentClick, + onCommentLongClick = onCommentLongClick ) } } @@ -88,13 +94,14 @@ fun CommentsRsTabListScreen( @Composable private fun CommentListContent( comments: List, + selectedIds: Set, + listState: LazyListState, isLoadingMore: Boolean, canLoadMore: Boolean, onLoadMore: () -> Unit, - onCommentClick: (Long) -> Unit + onCommentClick: (Long) -> Unit, + onCommentLongClick: (Long) -> Unit ) { - val listState = rememberLazyListState() - // Also keyed on the list size: a refresh that truncates the list (or an appended page) restarts // the flow, so a `true` latched by distinctUntilChanged before the change can't suppress the // re-fire needed to resume paging. The ViewModel's busy/cursor guards make re-fires safe. @@ -121,7 +128,9 @@ private fun CommentListContent( ) { comment -> CommentsRsListItem( comment = comment, + isSelected = comment.remoteCommentId in selectedIds, onClick = { onCommentClick(comment.remoteCommentId) }, + onLongClick = { onCommentLongClick(comment.remoteCommentId) }, modifier = Modifier.animateItem() ) } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 950741ce3bc9..08196703e395 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -434,6 +434,7 @@ No unreplied comments No spam comments No trashed comments + %1$d of %2$d comments couldn\'t be updated Reply to %s Comment trashed Comment marked as spam