diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index a83807714f..0f8ce18b8e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -36,6 +36,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -123,6 +124,7 @@ fun appEntryProvider( annotatedEntry { key -> ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> MuteChatScreen(key.chatId) } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt index 2a87c54f09..85ac12b99b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavBillOverlayEntryDecorator.kt @@ -31,12 +31,15 @@ import com.getcode.navigation.scrim.ScrimOverlay @Suppress("FunctionName") fun NavBillOverlayEntryDecorator(): NavEntryDecorator { return NavEntryDecorator { entry -> - Box(modifier = Modifier.fillMaxSize()) { + val isSheet = entry.metadata[NavMetadataKeys.IsSheet.key] == true + // A wrap-content sheet is sized by what it measures, so filling here would stretch it to + // the full expanded height. + val wrapsContent = entry.metadata[NavMetadataKeys.IsWrapContentSheet.key] == true + Box(modifier = if (wrapsContent) Modifier else Modifier.fillMaxSize()) { entry.Content() // Sheets are painted above the base scene by NavDisplay, so they must NOT carry the // bill themselves — otherwise the bill would cover the sheet again. - val isSheet = entry.metadata[NavMetadataKeys.IsSheet.key] == true if (!isSheet) { ScrimOverlay(LocalScrimController.current) BillOverlay() diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 95d9ff50e5..826bb72b0a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.services.models.chat.ChatId import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.onboarding.OnboardingStep @@ -401,6 +402,24 @@ sealed interface AppRoute : NavKey, Parcelable { override val initialStack: List get() = listOf(ReportStep.ReasonSelection) } + + /** + * How long to mute [chatId] for. + * + * A top-level route carrying the chat's id, for the reason [Report] is one: it is opened + * both from inside a chat (the DM and group profiles) and from the chat list, which has no + * chat flow behind it to ask which chat is meant. One route for both keeps the sheet and + * the mute request in one place. + * + * A [com.getcode.navigation.WrapContentSheet]: five rows at most, and whatever opened it is + * where you end up again once one is picked. + */ + @Serializable + @Parcelize + data class MuteChat(val chatId: ChatId) : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index a4d24633c8..af9adcc605 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -54,19 +54,6 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object InviteToGroup : ChatStep, Sheet, WrapContentSheet - /** - * How long to mute the conversation for. - * - * A [WrapContentSheet] for the same reason [InviteToGroup] is one: four rows, chosen from the - * group's profile, and the profile is where you end up again once one is picked. Carries - * nothing — the chat is the one the flow is already open on, and the shape the user picks - * travels back as an event rather than as a nav result, because muting is a request the view - * model makes rather than an answer the sheet returns. - */ - @Parcelize - @Serializable - data object MuteChat : ChatStep, Sheet, WrapContentSheet - @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep @@ -86,8 +73,8 @@ sealed interface ChatStep : FlowStep, Parcelable { /** * Node 10187:110373 — what about the group can be changed, as a list of the things that can. * - * A pushed screen rather than a sheet, unlike [InviteToGroup] and [MuteChat]: those end on the - * choice they present, while this one is a way through to another screen and has to be + * A pushed screen rather than a sheet, unlike [InviteToGroup]: that ends on the choice it + * presents, while this one is a way through to another screen and has to be * somewhere back can return to. Carries nothing for the same reason [GroupProfile] does not — * the flow is already open on the group, and every step here reads it off the conversation's * view model. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fe0d3b04f5..929df5953a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -939,6 +939,10 @@ same as a permanent one. --> Muted until %1$s Muted + + Mute notifications + Change mute Something Went Wrong We were unable to mute the chat. Please try again Something Went Wrong diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index cec34f2cb9..99764c26ee 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -24,7 +24,6 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen -import com.flipcash.app.messenger.internal.screens.MuteChatSheet import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen @@ -33,7 +32,6 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen -import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost @@ -71,8 +69,7 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard, route.openSendCash), - // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and - // ChatStep.MuteChat are Sheets, so the + // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own @@ -103,9 +100,6 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } - annotatedEntry { - FlowMuteChatSheet() - } annotatedEntry { step -> FlowChatProfileScreen(step.contact) @@ -278,35 +272,6 @@ private fun FlowGroupInviteSheet() { ) } -@Composable -private fun FlowMuteChatSheet() { - val viewModel = flowSharedViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates - // down rather than having its scene deleted mid-frame. - val dismissSheet = LocalBottomSheetDismissDispatcher.current - - MuteChatSheet( - // Asked per composition rather than passed in as a boolean the caller computed: a timed - // mute lapses with nothing sent to say so, and the sheet's unmute row is what would - // otherwise be left standing over a chat that is already audible again. - isMuted = rememberIsMuted(state.viewerState), - // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, - // and a failure is reported by the view model's own error bar, which draws over whatever is - // on screen by then. - onMute = { - viewModel.dispatchEvent(ChatViewModel.Event.MuteChat(it)) - dismissSheet() - }, - onUnmute = { - viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) - dismissSheet() - }, - onDismiss = dismissSheet, - ) -} - - @Composable private fun FlowChatProfileScreen(participant: ChatParticipant) { val viewModel = flowSharedViewModel() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt new file mode 100644 index 0000000000..25240839e1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.messenger + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.messenger.internal.MuteChatViewModel +import com.flipcash.app.messenger.internal.screens.MuteChatSheet +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.rememberIsMuted +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher + +/** + * The mute sheet for [chatId], behind `AppRoute.Messaging.MuteChat`, wherever it was opened from: + * a chat profile, a group profile, or a swipe on the chat list. + */ +@Composable +fun MuteChatScreen(chatId: ChatId) { + val viewModel = hiltViewModel() + val viewerStates = remember(viewModel, chatId) { viewModel.observeViewerState(chatId) } + val initial = remember(viewModel, chatId) { viewModel.currentViewerState(chatId) } + val viewerState by viewerStates.collectAsStateWithLifecycle(initialValue = initial) + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + MuteChatSheet( + // Asked per composition rather than passed in as a boolean the caller computed: a timed + // mute lapses with nothing sent to say so, and the sheet's unmute row is what would + // otherwise be left standing over a chat that is already audible again. + isMuted = rememberIsMuted(viewerState), + // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, + // and a failure is reported by the error bar, which draws over whatever is on screen by then. + onMute = { + viewModel.mute(chatId, it) + dismissSheet() + }, + onUnmute = { + viewModel.unmute(chatId) + dismissSheet() + }, + onDismiss = dismissSheet, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt index a790708054..0ca9807bba 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt @@ -18,6 +18,10 @@ import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.material3.Text 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.draw.alpha @@ -62,6 +66,11 @@ internal fun ChatMuteStatusChip( reserveSpace: Boolean = true, ) { val label = rememberMutedLabel(viewerState) + // The label goes null the moment the mute clears, while the chip is still fading out. Drawing + // that would empty the text on the exit's first frame and fade out a bell on its own, so the + // exit keeps the last label it showed. + var shownLabel by remember { mutableStateOf(label.orEmpty()) } + if (label != null && label != shownLabel) shownLabel = label Box(modifier = modifier, contentAlignment = Alignment.Center) { // Holds the line's height whether or not there is a mute, so the rows below don't move the @@ -84,7 +93,7 @@ internal fun ChatMuteStatusChip( enter = fadeIn() + scaleIn(initialScale = 0.85f, animationSpec = spring()), exit = fadeOut(animationSpec = tween(durationMillis = 200)), ) { - Chip(label = label.orEmpty()) + Chip(label = shownLabel) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 2d545745dd..2a4433a2f1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -45,7 +45,6 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.isDmAddressable @@ -479,17 +478,6 @@ internal class ChatViewModel @Inject constructor( /** This chat's viewer state moved, from the stream or from the viewer's own request. */ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event - /** - * A shape picked from the mute sheet. Carries the whole [MuteState] rather than a duration - * because a deadline and forever are not the same kind of thing, and the sheet is the last - * place that can tell them apart. - * - * No confirmation, unlike the leave: muting is undone by the row that replaces it. - */ - data class MuteChat(val mute: MuteState) : Event - - /** The group profile's "Unmute Chat" row. Its own request, not a mute of zero length. */ - data object UnmuteChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data class OnSendCashReadinessChanged(val ready: Boolean) : Event @@ -1738,39 +1726,6 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) - eventFlow.filterIsInstance() - .onEach { event -> - val chatId = stateFlow.value.chatId ?: return@onEach - // Nothing to dispatch on success: the stored viewer state is what the row reads, - // and `mute` writes it before returning, so the profile flips on the same flow the - // stream would have moved it on. - chatCoordinator.mute(chatId, event.mute) - .onFailure { - trace("failed to mute chat - ${it.localizedMessage}") - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToMute), - message = resources.getString(R.string.error_description_failedToMute), - ) - } - } - .launchIn(viewModelScope) - - eventFlow.filterIsInstance() - .onEach { - val chatId = stateFlow.value.chatId ?: return@onEach - chatCoordinator.unmute(chatId) - .onFailure { - trace("failed to unmute chat - ${it.localizedMessage}") - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToUnmute), - message = resources.getString( - R.string.error_description_failedToUnmute, - ), - ) - } - } - .launchIn(viewModelScope) - // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -2311,9 +2266,7 @@ internal class ChatViewModel @Inject constructor( Event.CopyInviteLink, Event.LeaveChat, Event.LeaveConfirmed, - Event.LeftChat, - is Event.MuteChat, - Event.UnmuteChat -> { state -> state } + Event.LeftChat -> { state -> state } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt new file mode 100644 index 0000000000..980a898d1a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Backs the mute sheet for one chat, named by id rather than read off a chat flow's view model. + * + * The sheet is opened from the chat list as well as from the profiles, and the list has no + * conversation behind it, so everything here is keyed by the id the route carries. Previously the + * requests lived on `ChatViewModel`, which only the profiles could reach. + */ +@HiltViewModel +internal class MuteChatViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : ViewModel() { + + /** + * The viewer state the feed already holds for [chatId], read without waiting. + * + * Seeds [observeViewerState] so the sheet opens with its unmute row already decided. Starting + * from `null` would open it without the row and then grow it by one as the store answered. + */ + fun currentViewerState(chatId: ChatId): ViewerState? = + chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP) + ?.firstOrNull { it.metadata.chatId == chatId } + ?.metadata?.viewerState + + /** + * Observed rather than read once: a mute made on another device arrives on the stream, and a + * timed one can lapse under the open sheet. + */ + fun observeViewerState(chatId: ChatId): Flow = + chatCoordinator.observeMetadata(chatId) + .map { it?.metadata?.viewerState } + .distinctUntilChanged() + + /** + * Nothing to report on success: `mute` stores the viewer state it answers with, and every + * surface showing the mute reads that store. + */ + fun mute(chatId: ChatId, mute: MuteState) = request( + errorTitle = R.string.error_title_failedToMute, + errorDescription = R.string.error_description_failedToMute, + ) { + chatCoordinator.mute(chatId, mute) + } + + fun unmute(chatId: ChatId) = request( + errorTitle = R.string.error_title_failedToUnmute, + errorDescription = R.string.error_description_failedToUnmute, + ) { + chatCoordinator.unmute(chatId) + } + + private fun request( + errorTitle: Int, + errorDescription: Int, + call: suspend () -> Result, + ) { + viewModelScope.launch { + // The sheet closes on the tap, and this view model is scoped to the sheet's entry, so + // its scope is cancelled while the request is still in flight. Cancelling there would + // drop the answer the store is written from, and the error bar with it. + withContext(NonCancellable) { + call().onFailure { + trace("failed to change chat mute - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(errorTitle), + message = resources.getString(errorDescription), + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 9bbc224a42..8d66667538 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -143,8 +143,12 @@ internal fun ChatProfileScreen( ChatProfileAction.Block -> viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) // Both muting and unmuting go through the picker, which is why this row - // navigates either way rather than acting on one of them here. - ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + // navigates either way rather than acting on one of them here. The outer + // navigator, as with Report: the sheet is a top-level route shared with the + // chat list, so it opens over the chat rather than inside it. + ChatProfileAction.Mute -> chatState.chatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId)) + } // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its // parent. So it opens over the chat rather than inside it. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index b2c28067a9..4aa5c060c8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -141,8 +141,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // it sits over the profile the user asked from. GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) // Both muting and unmuting go through the picker, which is why this row - // navigates either way rather than acting on one of them here. - GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + // navigates either way rather than acting on one of them here. The outer + // navigator, as with Report: the sheet is a top-level route shared with the + // chat list, so it opens over the chat rather than inside it. + GroupProfileAction.Mute -> state.chatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId)) + } GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) // The outer navigator, not this flow's: reporting is its own top-level diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 526951387b..0762b93c02 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -7,12 +7,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,6 +28,9 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -40,10 +48,15 @@ import com.flipcash.app.tipping.internal.ChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow import com.flipcash.features.tipping.R import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement @@ -60,6 +73,8 @@ fun ChatsScreen() { val chats = state.chats val listState = rememberLazyListState() + // One row open at a time: swiping another row closes the one left revealed. + val revealGroup = rememberSwipeRevealGroup() CodeScaffold( // The list runs the full height and passes under the title bar, which fades it out against @@ -122,9 +137,18 @@ fun ChatsScreen() { if (chats.isLoaded() && chats.data.isEmpty()) { item { NoChatsYet(Modifier.fillParentMaxSize()) } } else { - tipChatItems(chats.dataOrNull.orEmpty()) { chat -> - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId))) - } + tipChatItems( + chats = chats.dataOrNull.orEmpty(), + onClick = { chat -> + navigator.push( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) + ) + }, + // The same sheet the chat and group profiles open, so the list offers exactly + // the durations they do, and unmuting is its "Never" row rather than a toggle. + onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId)) }, + revealGroup = revealGroup, + ) } } } @@ -181,17 +205,80 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { private fun LazyListScope.tipChatItems( chats: List, onClick: (ConversationReference) -> Unit, + onMute: (ConversationReference) -> Unit, + revealGroup: SwipeRevealGroup, ) { - itemsIndexed(chats) { index, chat -> - TipChatRow( - chat = chat, - showDivider = index < chats.lastIndex, + // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + MuteSwipeRow( + isMuted = rememberIsMuted(chat.viewerState), + onMute = { onMute(chat) }, + stateKey = chat.chatId, + revealGroup = revealGroup, ) { - onClick(chat) + TipChatRow( + chat = chat, + showDivider = index < chats.lastIndex, + ) { + onClick(chat) + } } } } +/** + * A trailing swipe that opens the mute sheet, whichever way the chat is muted. + * + * The icon names the state the chat would be moved out of: a crossed-out bell on an audible chat, + * and a plain one on a muted chat, where the sheet is also how the mute is cleared. It never mutes + * directly, because a mute always needs a duration. + * + * Resets rather than settling open: a full swipe opens the sheet, and a row left swiped under it + * would still be sitting open once the sheet closed. + */ +@Composable +private fun MuteSwipeRow( + isMuted: Boolean, + onMute: () -> Unit, + stateKey: Any, + revealGroup: SwipeRevealGroup, + content: @Composable () -> Unit, +) { + val label = stringResource( + if (isMuted) R.string.content_description_changeMute + else R.string.content_description_muteChat + ) + SwipeActionRow( + actions = listOf( + SwipeAction( + // The profile's unverified badge treatment rather than the delete red: nothing is + // lost by it. + background = CodeTheme.colors.warning.copy(alpha = 0.15f), + onTriggered = onMute, + resetOnDismiss = true, + ) { + Icon( + imageVector = if (isMuted) { + Icons.Outlined.Notifications + } else { + Icons.Outlined.NotificationsOff + }, + contentDescription = label, + tint = CodeTheme.colors.warning, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + ), + // A swipe is out of reach with a screen reader, so the row offers the same action there. + modifier = Modifier.semantics { + customActions = listOf(CustomAccessibilityAction(label) { onMute(); true }) + }, + stateKey = stateKey, + revealGroup = revealGroup, + content = content, + ) +} + @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index b0764547fd..77baa7e366 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,7 +1,6 @@ package com.flipcash.app.tipping.internal.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize @@ -11,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper @@ -46,14 +46,28 @@ internal fun TipChatRow( ) }, title = { - Text( + Row( modifier = Modifier.weight(1f), - // Name, or the `@handle` when there isn't one — the row's single line of identity - // (node 9442:103645 has the preview under it, so there is nowhere else to put it). - text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + // Takes only what it needs, so the bell trails the name itself rather than + // the far edge, and a long name ellipsizes before it pushes the bell off. + modifier = Modifier.weight(1f, fill = false), + // Name, or the `@handle` when there isn't one — the row's single line of + // identity (node 9442:103645 has the preview under it, so there is nowhere + // else to put it). + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // An audible chat emits no indicator at all, so the gap is not spent either. + MutedIndicator(viewerState = chat.viewerState) + } ChatRowTrailing( lastActivity = chat.lastActivity, @@ -62,31 +76,17 @@ internal fun TipChatRow( ) }, subtitle = { - // The mute sits at the end of this line rather than the one above it, where the - // timestamp and unread badge already compete for the trailing corner. It reads as the - // bottom of a column of chat-level state, and matches where iOS draws it. - Row( - verticalAlignment = Alignment.CenterVertically, - // An audible chat emits no indicator at all, so the gap is not spent either and - // the preview keeps the full width. - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Box(modifier = Modifier.weight(1f)) { - ChatRowSubtitle( - isTyping = chat.isTyping, - preview = chat.lastMessagePreview, - hasMessages = chat.hasMessages, - fallback = { - // Blank rather than absent: a chat with a message the row cannot - // preview still occupies both lines, so it doesn't jump in height - // against its neighbours. - SubtitleText("") - } - ) + ChatRowSubtitle( + isTyping = chat.isTyping, + preview = chat.lastMessagePreview, + hasMessages = chat.hasMessages, + fallback = { + // Blank rather than absent: a chat with a message the row cannot preview + // still occupies both lines, so it doesn't jump in height against its + // neighbours. + SubtitleText("") } - - MutedIndicator(viewerState = chat.viewerState) - } + ) }, showDivider = showDivider, onClick = onClick, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt index 7841929c7e..f892534968 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -5,7 +5,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState +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.res.stringResource import com.flipcash.core.R @@ -24,7 +28,7 @@ import kotlin.time.Clock * already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops * drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it. * - * Sized and tinted to sit with the caption text it stands beside — the row's preview, the title + * Sized and tinted to sit quietly beside the name it follows — the chat list row's, the title * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for * anything. Filled rather than outlined, matching iOS' `bell.slash.fill`. The mute row and the * profile chip stay outlined, as iOS' do — those sit at body size beside a label that already @@ -61,11 +65,15 @@ fun MutedIndicator( @Composable fun rememberIsMuted(viewerState: ViewerState?): Boolean { val mute = viewerState?.mute - return produceState(initialValue = viewerState.isMutedAt(), mute) { - value = viewerState.isMutedAt() - val until = (mute as? MuteState.Until)?.until ?: return@produceState + // Only the lapse is waited for. The state itself is read in the same pass it changes in: a + // producer re-answering it would hold the previous answer for a frame, which on an unmute is a + // frame of "muted" with no deadline, drawn as a plain "Muted". + var lapsed by remember(mute) { mutableStateOf(false) } + LaunchedEffect(mute) { + val until = (mute as? MuteState.Until)?.until ?: return@LaunchedEffect val remaining = until - Clock.System.now() if (remaining.isPositive()) delay(remaining) - value = false - }.value + lapsed = true + } + return !lapsed && viewerState.isMutedAt() } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt index da94f4022f..7ff1638336 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -2,6 +2,8 @@ package com.getcode.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable @@ -33,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -73,6 +76,20 @@ class SwipeAction( private enum class SwipeState { Settled, Revealed, Dismissed } +/** + * Keeps at most one [SwipeActionRow] open among the rows given the same group: starting a swipe on + * one row closes whichever other row was left revealed. + * + * Rows are told apart by their `stateKey`, so give each row in a group a distinct one. + */ +@Stable +class SwipeRevealGroup { + internal var openKey: Any? by mutableStateOf(null) +} + +@Composable +fun rememberSwipeRevealGroup(): SwipeRevealGroup = remember { SwipeRevealGroup() } + /** * Convenience overload that wraps [content] with a single red delete action. */ @@ -124,6 +141,7 @@ fun SwipeActionRow( modifier: Modifier = Modifier, stateKey: Any? = null, initiallyRevealed: Boolean = false, + revealGroup: SwipeRevealGroup? = null, content: @Composable () -> Unit, ) { if (actions.isEmpty()) { @@ -163,11 +181,47 @@ fun SwipeActionRow( } var rowWidthPx by remember(stateKey) { mutableFloatStateOf(0f) } - // Fire callback once for rejected dismiss (reset case) + // Fire callback once for rejected dismiss (reset case), then send the row home. A rejected + // settle does not do that by itself: the drag settles on the nearest anchor it is allowed, + // which after a full swipe is Revealed, and the row stays open under whatever the callback + // opened. LaunchedEffect(pendingResetCallback) { if (pendingResetCallback) { currentDismissCallback() - pendingResetCallback = false + state.animateTo(SwipeState.Settled) + } + } + + val dragInteractions = remember { MutableInteractionSource() } + // The reset flag is re-armed by the next drag rather than by the effect above finishing. The + // settle asks to confirm Dismissed on every frame it heads there, so the flag has to stay set + // for the rest of the gesture to fire the callback once; and the effect can be left suspended + // by the settle interrupting its animation, which must not leave the flag stuck at true for + // the next swipe to write again without relaunching anything. + LaunchedEffect(dragInteractions) { + dragInteractions.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) pendingResetCallback = false + } + } + if (revealGroup != null) { + val fallbackKey = remember { Any() } + val groupKey = stateKey ?: fallbackKey + // Claimed when the drag starts rather than when it settles, so the other row closes while + // this one opens. Keyed to the drag and not to the offset, which also moves while a row + // is closing and would have it claim the group straight back. + LaunchedEffect(dragInteractions, revealGroup, groupKey) { + dragInteractions.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) revealGroup.openKey = groupKey + } + } + LaunchedEffect(state, revealGroup, groupKey) { + snapshotFlow { revealGroup.openKey }.collect { openKey -> + if (openKey != null && openKey != groupKey && + state.currentValue != SwipeState.Settled + ) { + state.animateTo(SwipeState.Settled) + } + } } } @@ -252,7 +306,11 @@ fun SwipeActionRow( Box( modifier = Modifier .offset { IntOffset(state.offset.toInt(), 0) } - .anchoredDraggable(state, Orientation.Horizontal), + .anchoredDraggable( + state = state, + orientation = Orientation.Horizontal, + interactionSource = dragInteractions, + ), ) { content() }