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 ee5639881..4c53fa8b6 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 @@ -272,11 +272,16 @@ private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileO viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) } + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + // Only a profile opened from the chat itself has that chat underneath; a member's portrait in + // a group is ProfileOrigin.Chat too, but its DM is not the group. + val chatUnderneath = chatState.chatId.takeIf { origin == ProfileOrigin.Chat } + ChatProfileScreen( viewModel = viewModel, + chatUnderneath = chatUnderneath, onOpenChat = { chatId -> - val chatState = chatViewModel.stateFlow.value - if (origin == ProfileOrigin.Chat && chatState.chatId == chatId) { + if (chatUnderneath != null && chatUnderneath == chatId) { // The chat is the one underneath: back to it rather than a second copy. flowNavigator.back() } else { 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 e71527e44..f560ba7cb 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 @@ -7,9 +7,12 @@ import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.rememberScrollState @@ -57,6 +60,7 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.BottomScrollEdge import com.getcode.ui.components.CircularIconButton import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.hazeSource @@ -73,6 +77,7 @@ import com.flipcash.app.core.chat.ChatStep @Composable internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, + chatUnderneath: ChatId?, onOpenChat: (ChatId) -> Unit, ) { val flowNavigator = rememberFlowNavigator() @@ -80,6 +85,7 @@ internal fun ChatProfileScreen( viewModel = viewModel, onBack = { flowNavigator.back() }, onOpenChat = onOpenChat, + chatUnderneath = chatUnderneath, ) } @@ -93,6 +99,8 @@ internal fun PersonProfileScreen( viewModel: ChatProfileViewModel, onBack: () -> Unit, onOpenChat: (ChatId) -> Unit, + /** The chat this profile was opened from, when it is the DM with this person; null otherwise. */ + chatUnderneath: ChatId? = null, ) { val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -121,7 +129,8 @@ internal fun PersonProfileScreen( val person = state.participant as? ChatParticipant.TipUser val isSelf = person != null && person.userId == state.selfId - val pinned = if (isSelf) null else state.pinnedAction + val pinned = state.pinnedAction + ?.takeUnless { isSelf || it.opensChatUnderneath(state.dmChatId, chatUnderneath) } var menuOpen by remember { mutableStateOf(false) } var pinnedHeight by remember { mutableStateOf(0.dp) } @@ -132,7 +141,7 @@ internal fun PersonProfileScreen( val hazeState = rememberHazeState() CodeScaffold { padding -> - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding) @@ -145,6 +154,8 @@ internal fun PersonProfileScreen( // The whole page, so the pinned bar and the top edge blur whatever scrolls under them. .hazeSource(hazeState) .verticalScroll(scrollState) + // At least a screen tall, so a short page can hold the encryption note to the bottom. + .heightIn(min = maxHeight) .padding(bottom = clearance), ) { ProfileHeader( @@ -236,9 +247,28 @@ internal fun PersonProfileScreen( ) }, ) + // The encryption note closes the page, under the pinned button rather than on it. + if (state.dmExists && state.isEncrypted) { + // With nothing pinned, the note takes the bottom of a short page. + if (pinned == null) Spacer(modifier = Modifier.weight(1f)) + E2eeFooter( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x4), + isEncrypted = true, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, + // The pinned button's clearance already covers the system bar. + clearNavigationBar = pinned == null, + ) + } } ProfileTopScrollEdge(hazeState = hazeState, scrollState = scrollState) + if (pinned == null) { + BottomScrollEdge( + modifier = Modifier.align(Alignment.BottomCenter), + hazeState = hazeState, + scrollState = scrollState, + ) + } AppBarWithTitle( onBackIconClicked = onBack, @@ -290,15 +320,6 @@ internal fun PersonProfileScreen( text = pinned.label(), isLoading = pinned == ProfilePinnedAction.OpeningChat, onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, - above = if (state.dmExists && state.isEncrypted) { - { - E2eeFooter( - isEncrypted = true, - onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, - clearNavigationBar = false, - ) - } - } else null, hazeState = hazeState, onHeightChanged = { pinnedHeight = 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 b0265090e..86dc2278e 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 @@ -9,11 +9,16 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -75,7 +80,10 @@ import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.BottomScrollEdge import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -117,9 +125,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The chat is usually the screen underneath. A flow opened on the profile // (AppRoute.Messaging.Chat.openOnProfile) has nothing underneath, so the transcript is pushed on - // top and back from it returns here, as iOS does. + // top and back from it returns here, as iOS does. Read once: back pops the stack before this + // screen finishes animating out, and a live read would bring the pinned bar back for the exit. + val openedFromChat = remember { flowNavigator.canGoBack } + fun openChat() { - if (flowNavigator.canGoBack) { + if (openedFromChat) { flowNavigator.back() } else { flowNavigator.navigateTo(ChatStep.Conversation) @@ -145,11 +156,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { var menuOpen by remember { mutableStateOf(false) } var pinnedHeight by remember { mutableStateOf(0.dp) } - val clearance = pinnedHeight + // Opened from the chat, back already returns to it, so nothing is pinned: Leave Chat sits at + // the foot of the page instead. + val clearance = if (openedFromChat) 0.dp else pinnedHeight val hazeState = rememberHazeState() CodeScaffold { padding -> - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding) @@ -162,6 +175,8 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The whole page, so the pinned bar and the top edge blur whatever scrolls under them. .hazeSource(hazeState) .verticalScroll(scrollState) + // At least a screen tall, so a short page can hold Leave Chat to the bottom. + .heightIn(min = maxHeight) .padding(bottom = clearance), ) { ProfileHeader( @@ -291,10 +306,36 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { }, ) } - Box(modifier = Modifier.padding(bottom = CodeTheme.dimens.staticGrid.x6)) + if (openedFromChat && isMember) { + // Takes up whatever the page leaves over; nothing once it scrolls. + Spacer(modifier = Modifier.weight(1f)) + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x6), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_leaveChat), + isLoading = state.leaving, + enabled = !state.leaving, + onClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, + ) + } + Box( + modifier = Modifier + .then(if (openedFromChat) Modifier.navigationBarsPadding() else Modifier) + .padding(bottom = CodeTheme.dimens.staticGrid.x6) + ) } ProfileTopScrollEdge(hazeState = hazeState, scrollState = scrollState) + if (openedFromChat) { + BottomScrollEdge( + modifier = Modifier.align(Alignment.BottomCenter), + hazeState = hazeState, + scrollState = scrollState, + ) + } AppBarWithTitle( onBackIconClicked = { flowNavigator.back() }, @@ -337,16 +378,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // Open Chat in every standing, as in the design: a viewer short of a minimum is told // so, and buys, on the chat's own gate. - ProfilePinnedActionBar( - modifier = Modifier.align(Alignment.BottomCenter), - text = stringResource(R.string.action_openChat), - onClick = { openChat() }, - secondaryText = if (isMember) stringResource(R.string.action_leaveChat) else null, - onSecondaryClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, - isSecondaryLoading = state.leaving, - hazeState = hazeState, - onHeightChanged = { pinnedHeight = it }, - ) + if (!openedFromChat) { + ProfilePinnedActionBar( + modifier = Modifier.align(Alignment.BottomCenter), + text = stringResource(R.string.action_openChat), + onClick = { openChat() }, + secondaryText = if (isMember) stringResource(R.string.action_leaveChat) else null, + onSecondaryClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, + isSecondaryLoading = state.leaving, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt index 9aae82644..5312a08f9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.annotation.StringRes import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.financial.Fiat /** The one primary action pinned to the bottom of another user's profile. */ @@ -35,6 +36,13 @@ internal fun resolvePinnedAction( else -> fee?.let(ProfilePinnedAction::StartChatting) } +/** + * Whether the pinned button is left off because it would only open [chatUnderneath], the chat + * this profile was opened from: back already returns there. + */ +internal fun ProfilePinnedAction.opensChatUnderneath(dmChatId: ChatId?, chatUnderneath: ChatId?): Boolean = + this == ProfilePinnedAction.OpenChat && chatUnderneath != null && dmChatId == chatUnderneath + /** * The rows under another user's profile, in order: the reversible and routine first, the one that * ends the conversation last. Muting is the DM's, so it only appears once one exists. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt index 1c79b1f80..10f318394 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.financial.Fiat import org.junit.Test import kotlin.test.assertEquals @@ -102,6 +103,33 @@ class ProfileActionsTest { assertFalse(ChatProfileAction.Unblock.isDestructive) } + // -- opensChatUnderneath -- + + private val dm = ChatId(List(16) { 1.toByte() }) + private val group = ChatId(List(16) { 2.toByte() }) + + @Test + fun `open chat is left off when the DM is the chat underneath`() { + assertTrue(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + } + + @Test + fun `open chat stays when the chat underneath is another chat`() { + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = group)) + } + + @Test + fun `open chat stays with no chat underneath`() { + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = null)) + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = null, chatUnderneath = null)) + } + + @Test + fun `only open chat is ever left off`() { + assertFalse(ProfilePinnedAction.Unblock.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + assertFalse(ProfilePinnedAction.OpeningChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + } + // -- labelRes -- @Test diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt new file mode 100644 index 000000000..846ff5e31 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt @@ -0,0 +1,59 @@ +package com.getcode.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials + +/** + * [TopScrollEdge] turned over, for a page with nothing pinned to its foot: content blurs out toward + * the bottom of the screen as it scrolls under the navigation bar. + * + * Place it over the scroll content, aligned to the bottom, reading the same [hazeState] the content + * is a source for. It shows while [scrollState] has more to scroll to, so the page's last line reads + * clean once it is reached. [height] defaults to the navigation bar plus [fade]. + */ +@Composable +fun BottomScrollEdge( + hazeState: HazeState, + scrollState: ScrollState, + modifier: Modifier = Modifier, + fade: Dp = 40.dp, + height: Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + fade, +) { + val visible by remember(scrollState) { derivedStateOf { scrollState.canScrollForward } } + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + + AnimatedVisibility(visible = visible, modifier = modifier, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .hazeBlur( + HazeInput.Sources(hazeState), + material.then { + progressive(HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f)) + }, + ), + ) + } +}