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 @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -73,13 +77,15 @@ import com.flipcash.app.core.chat.ChatStep
@Composable
internal fun ChatProfileScreen(
viewModel: ChatProfileViewModel,
chatUnderneath: ChatId?,
onOpenChat: (ChatId) -> Unit,
) {
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
PersonProfileScreen(
viewModel = viewModel,
onBack = { flowNavigator.back() },
onOpenChat = onOpenChat,
chatUnderneath = chatUnderneath,
)
}

Expand All @@ -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()
Expand Down Expand Up @@ -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) }
Expand All @@ -132,7 +141,7 @@ internal fun PersonProfileScreen(
val hazeState = rememberHazeState()

CodeScaffold { padding ->
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(padding)
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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 },
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
},
),
)
}
}
Loading