Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +124,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Report> { key ->
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId) }

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ import com.getcode.navigation.scrim.ScrimOverlay
@Suppress("FunctionName")
fun NavBillOverlayEntryDecorator(): NavEntryDecorator<NavKey> {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -401,6 +402,24 @@ sealed interface AppRoute : NavKey, Parcelable {
override val initialStack: List<NavKey>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,10 @@
same as a permanent one. -->
<string name="label_mutedUntil">Muted until %1$s</string>
<string name="label_muted">Muted</string>
<!-- The chat list's swipe action. Both open the mute sheet: the first on an audible chat, the
second on a muted one, where the sheet is also how the mute is cleared. -->
<string name="content_description_muteChat">Mute notifications</string>
<string name="content_description_changeMute">Change mute</string>
<string name="error_title_failedToMute">Something Went Wrong</string>
<string name="error_description_failedToMute">We were unable to mute the chat. Please try again</string>
<string name="error_title_failedToUnmute">Something Went Wrong</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -103,9 +100,6 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
}
annotatedEntry<ChatStep.MuteChat> {
FlowMuteChatSheet()
}

annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact)
Expand Down Expand Up @@ -278,35 +272,6 @@ private fun FlowGroupInviteSheet() {
)
}

@Composable
private fun FlowMuteChatSheet() {
val viewModel = flowSharedViewModel<ChatViewModel>()
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<ChatProfileViewModel>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MuteChatViewModel>()
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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1738,39 +1726,6 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.MuteChat>()
.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<Event.UnmuteChat>()
.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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading