From e020052bd9a9c8923770d3797a7c2c0820c19382 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:09:35 -0400 Subject: [PATCH 1/4] feat(profile): show favourite public groups on profiles Both profiles get a Favorite Public Groups section under the stats card, hidden while empty. The signed-in user's list is held for the session in FeaturedGroupsStore and cleared on sign-out; another person's is read by handle when their profile opens, and a failed read leaves the section hidden. Rows open the group chat by id. --- .../core/src/main/res/values/strings.xml | 17 ++ apps/flipcash/features/menu/build.gradle.kts | 1 + .../app/menu/internal/MenuScreenContent.kt | 18 +++ .../app/menu/internal/MenuScreenViewModel.kt | 26 +++ .../screens/profile/ChatProfileScreen.kt | 12 ++ .../screens/profile/ChatProfileViewModel.kt | 23 +++ .../profile/ChatProfileViewModelTest.kt | 40 +++++ .../shared/chat/FeaturedGroupsStore.kt | 47 ++++++ .../shared/chat/FeaturedGroupsStoreTest.kt | 66 ++++++++ .../ui/profile/FeaturedGroupsSection.kt | 150 ++++++++++++++++++ .../session/internal/RealSessionController.kt | 3 + .../SessionControllerEventRoutingTest.kt | 1 + .../SessionControllerGiftCardErrorTest.kt | 1 + .../SessionControllerShareCheckTest.kt | 1 + 14 files changed, 406 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/FeaturedGroupsStore.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeaturedGroupsStoreTest.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4bd1945967..585c8c363b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1359,6 +1359,23 @@ Bio Add a bio + + Favorite Public Groups + Add groups + + 1 group + %1$s groups + + Favorite Groups + Search your public groups + %1$s of %2$s selected + These public groups will appear on your public profile + Couldn\'t load your groups + Join a public group to feature it on your profile + Only Public Groups Can Be Featured + One of these groups is private. Remove it and try again + Couldn\'t Save Your Groups + Try again This bio isn\'t allowed. Try different wording This bio isn\'t valid Something Went Wrong diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index d16b3fbd7d..9ae1e2acef 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bills")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:payments")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 773af40b5a..1ef8b4c968 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -50,6 +50,7 @@ import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.bills.components.cards.TipCardFlattened import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.ProfileState @@ -59,9 +60,11 @@ import com.flipcash.features.menu.R import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.FeaturedGroupsSection import com.flipcash.shared.common.ui.profile.ProfileActionButton import com.flipcash.shared.common.ui.profile.ProfileHeader import com.flipcash.shared.common.ui.profile.ProfileStatsCard +import com.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White08 @@ -154,6 +157,21 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { joined = profileState.joined, ) } + item(key = "featured_groups") { + FeaturedGroupsSection( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + groups = rememberFeaturedGroupItems(state.featuredGroups), + onOpen = { chatId -> + viewModel.dispatchEvent( + Event.OpenScreen( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)) + ) + ) + }, + ) + } item(key = "bottom_spacer") { Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) } } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 3bede68c70..ce7988bbb7 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -18,10 +18,12 @@ import com.flipcash.app.userflags.usernameGate import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.common.ui.profile.joinedLabel import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -58,6 +61,7 @@ internal class MenuScreenViewModel @Inject constructor( analytics: FlipcashAnalytics, private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, + private val featuredGroupsStore: FeaturedGroupsStore, tipPayments: TipPaymentDelegate, private val resources: ResourceHelper, ) : @@ -78,6 +82,8 @@ internal class MenuScreenViewModel @Inject constructor( // What another user must send to open a chat with the viewer, formatted. Null until it // resolves, which the stats card draws as a dash. val minimumToChat: String? = null, + // The viewer's own featured groups, in profile order. Empty until the first read lands. + val featuredGroups: List = emptyList(), ) /** @@ -122,6 +128,7 @@ internal class MenuScreenViewModel @Inject constructor( val minimumBalance: String, ) : Event data class OnMinimumToChatChanged(val minimumToChat: String?) : Event + data class OnFeaturedGroupsChanged(val groups: List) : Event /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ data object ClaimUsername : Event @@ -186,6 +193,21 @@ internal class MenuScreenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) } .launchIn(viewModelScope) + // The featured groups are held for the session, so a save in the picker reaches this tab + // without a read. The read here is for the first look and for a handle that changes. + featuredGroupsStore.groups + .onEach { dispatchEvent(Event.OnFeaturedGroupsChanged(it)) } + .launchIn(viewModelScope) + + userManager.state + .filter { it.authState is AuthState.Ready } + .map { it.userProfile?.username } + .filterNotNull() + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .onEach { featuredGroupsStore.load(it) } + .launchIn(viewModelScope) + // The username nudge. Gated on Ready for the same reason as the tip card: a named account // restores its cached profile before auth completes, so the card would otherwise flash for // someone who already holds a handle. @@ -298,6 +320,10 @@ internal class MenuScreenViewModel @Inject constructor( state.copy(minimumToChat = event.minimumToChat) } + is Event.OnFeaturedGroupsChanged -> { state -> + state.copy(featuredGroups = event.groups) + } + is Event.PresentDepositOptions, Event.ClaimTipCard, Event.ClaimUsername, 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 d5f5ba0408..97f40c3e2f 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 @@ -34,6 +34,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.messenger.internal.rememberMutedLabel @@ -42,12 +43,14 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.FeaturedGroupsSection import com.flipcash.shared.common.ui.profile.ProfileActionButton import com.flipcash.shared.common.ui.profile.ProfileHeader import com.flipcash.shared.common.ui.profile.ProfilePinnedActionBar import com.flipcash.shared.common.ui.profile.ProfileStatsCard import com.flipcash.shared.common.ui.profile.ProfileStatusChip import com.flipcash.shared.common.ui.profile.joinedLabel +import com.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -206,6 +209,15 @@ internal fun PersonProfileScreen( minimumToChat = state.fee?.takeIf { state.profileSettled }?.formatted(), joined = joinedLabel(state.joinDate), ) + FeaturedGroupsSection( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + groups = rememberFeaturedGroupItems(state.featuredGroups), + onOpen = { chatId -> + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + }, + ) } AppBarWithTitle( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index f7a9d0d154..bd5277e085 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -10,8 +10,10 @@ import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager @@ -63,6 +65,7 @@ internal class ChatProfileViewModel @Inject constructor( private val tipPaymentDelegate: TipPaymentDelegate, private val e2eePolicy: E2eePolicy, private val startChattingPayer: StartChattingPayer, + private val chatController: ChatController, ) : BaseViewModel( initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, @@ -113,6 +116,11 @@ internal class ChatProfileViewModel @Inject constructor( * and cannot be dismissed until the chat is ready, or the payment fails. */ val paymentInProgress: Boolean = false, + /** + * The public groups this person features. Empty until the read lands, and after one that + * fails: the section hides rather than say the read went wrong. + */ + val featuredGroups: List = emptyList(), ) { /** Null for your own profile, for anyone but a tip user, and until [profileSettled]. */ val pinnedAction: ProfilePinnedAction? @@ -161,6 +169,7 @@ internal class ChatProfileViewModel @Inject constructor( val viewerState: ViewerState? = null, ) : Event data class FeeLoaded(val fee: Fiat?) : Event + data class FeaturedGroupsLoaded(val groups: List) : Event data class TokenUpdated(val token: Token) : Event /** The pinned button was tapped; what it does is [State.pinnedAction]'s. */ @@ -290,6 +299,19 @@ internal class ChatProfileViewModel @Inject constructor( .onEach { dispatchEvent(Event.BlockedChanged(it)) } .launchIn(viewModelScope) + // Keyed on the handle because that is all the read takes, and a cached participant may not + // carry one until the full profile replaces it. Latest: groups fetched for someone the + // screen has moved on from are dropped. + stateFlow + .map { (it.participant as? ChatParticipant.TipUser)?.profile?.username?.takeIf { name -> name.isNotBlank() } } + .filterNotNull() + .distinctUntilChanged() + .collectLatestIn(viewModelScope) { username -> + dispatchEvent(Event.FeaturedGroupsLoaded(emptyList())) + chatController.getFeaturedGroups(username) + .onSuccess { dispatchEvent(Event.FeaturedGroupsLoaded(it)) } + } + // Nullable on purpose: moving to a person with no DM yet has to end the last person's // subscriptions, and coming back to one has to start them again. val chatIds = stateFlow.map { it.dmChatId }.distinctUntilChanged() @@ -456,6 +478,7 @@ internal class ChatProfileViewModel @Inject constructor( val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnParticipantSet -> { state -> state.withParticipantSet(event) } + is Event.FeaturedGroupsLoaded -> { state -> state.copy(featuredGroups = event.groups) } is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) } is Event.ProfileLoaded -> { state -> // A fetch that lands after the screen moved on is someone else's. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index 4f2bdbd0e1..b28daa0f46 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -8,7 +8,10 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.shared.chat.ChatCoordinator @@ -53,6 +56,9 @@ class ChatProfileViewModelTest { private val serverJoin = Instant.fromEpochSeconds(1_710_000_000) private val profiles = mockk() + private val chatController = mockk { + coEvery { getFeaturedGroups(any()) } returns Result.success(emptyList()) + } private val userManager = mockk { every { accountId } returns List(16) { 1 } } private val dmChatId = ChatId(ByteArray(32) { 7 }) @@ -88,6 +94,7 @@ class ChatProfileViewModelTest { tipPaymentDelegate = tipPaymentDelegate, e2eePolicy = mockk(relaxed = true), startChattingPayer = payer, + chatController = chatController, ) private fun openFull(model: ChatProfileViewModel) = @@ -135,6 +142,39 @@ class ChatProfileViewModelTest { assertEquals("Sally", model.stateFlow.value.participant?.name) } + private fun featured(hex: String) = ChatMetadata( + chatId = ChatId(hex), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + ) + + @Test + fun `a person's featured groups are read by their handle`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.success(participant(serverJoin).profile) + val groups = listOf(featured("aa"), featured("bb")) + coEvery { chatController.getFeaturedGroups("sally_streamer") } returns Result.success(groups) + val model = viewModel() + + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin))) + + assertEquals(groups, model.stateFlow.value.featuredGroups) + } + + @Test + fun `a failed featured groups read leaves the section hidden`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.success(participant(serverJoin).profile) + coEvery { chatController.getFeaturedGroups(any()) } returns Result.failure(Exception("offline")) + val model = viewModel() + + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin))) + + assertEquals(emptyList(), model.stateFlow.value.featuredGroups) + } + @Test fun `a failed fetch keeps the cached participant`() = runTest { coEvery { profiles.getProfileForUser(theirId) } returns Result.failure(Exception("offline")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/FeaturedGroupsStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/FeaturedGroupsStore.kt new file mode 100644 index 0000000000..d32b36f31b --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/FeaturedGroupsStore.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatMetadata +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The public groups the signed-in user has chosen to show on their own profile, in the order the + * profile shows them. + * + * Held in memory for the session and kept out of the chat tables: the server is the only source, + * the groups come back in list-view shape (no members or last message) and nothing else reads + * them as chats. Starts empty, and [reset] puts it back there on sign-out. + * + * The You tab, the Edit Profile row and the group picker all read the same list, so a save in the + * picker shows up in both without another fetch. Other people's featured groups are not kept + * here; their profile screen fetches them itself. + */ +@Singleton +class FeaturedGroupsStore @Inject constructor( + private val chatController: ChatController, +) { + private val _groups = MutableStateFlow>(emptyList()) + val groups: StateFlow> = _groups.asStateFlow() + + /** + * Re-reads [username]'s featured groups from the server. Returns false on failure, and the + * list already held stays: a dropped request must not blank a profile that was showing groups. + */ + suspend fun load(username: String): Boolean = + chatController.getFeaturedGroups(username) + .onSuccess { _groups.value = it } + .isSuccess + + /** Adopts the list the server returned from a save. */ + fun replace(groups: List) { + _groups.value = groups + } + + fun reset() { + _groups.value = emptyList() + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeaturedGroupsStoreTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeaturedGroupsStoreTest.kt new file mode 100644 index 0000000000..bcdc25872a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeaturedGroupsStoreTest.kt @@ -0,0 +1,66 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class FeaturedGroupsStoreTest { + + private val chatController = mockk() + private val store = FeaturedGroupsStore(chatController) + + private fun group(hex: String) = ChatMetadata( + chatId = ChatId(hex), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(0), + title = hex, + ) + + @Test + fun `starts empty`() { + assertEquals(emptyList(), store.groups.value) + } + + @Test + fun `a load replaces the list with the server's order`() = runTest { + val groups = listOf(group("bb"), group("aa")) + coEvery { chatController.getFeaturedGroups("me") } returns Result.success(groups) + + assertTrue(store.load("me")) + + assertEquals(groups, store.groups.value) + } + + @Test + fun `a failed load keeps the list already held`() = runTest { + val held = listOf(group("aa")) + coEvery { chatController.getFeaturedGroups("me") } returns Result.success(held) + store.load("me") + + coEvery { chatController.getFeaturedGroups("me") } returns Result.failure(Throwable("offline")) + + assertFalse(store.load("me")) + assertEquals(held, store.groups.value) + } + + @Test + fun `replace adopts a saved list and reset empties it`() { + store.replace(listOf(group("aa"))) + assertEquals(listOf(group("aa")), store.groups.value) + + store.reset() + + assertEquals(emptyList(), store.groups.value) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt new file mode 100644 index 0000000000..60b73e4cd5 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt @@ -0,0 +1,150 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.R +import com.getcode.theme.CodeTheme + +private val PictureSize = 48.dp + +/** One group as the profile and the picker draw it, already resolved to what is shown. */ +data class FeaturedGroupItem( + val chatId: ChatId, + val title: String, + /** The group's description, or its member count when it has none. */ + val subtitle: String, + val picture: MediaItem?, +) + +/** + * The groups a person has chosen to feature, under the stats on their profile. Draws nothing for + * an empty list, heading and divider included, so a profile without featured groups looks the + * same as before the section existed. + * + * Rows arrive as [FeaturedGroupItem]s and report the tap by id; what opening a group does is the + * caller's. + */ +@Composable +fun FeaturedGroupsSection( + groups: List, + onOpen: (ChatId) -> Unit, + modifier: Modifier = Modifier, +) { + if (groups.isEmpty()) return + + Column(modifier = modifier.fillMaxWidth()) { + HorizontalDivider(color = CodeTheme.colors.divider) + Text( + modifier = Modifier.padding(vertical = CodeTheme.dimens.staticGrid.x5), + text = stringResource(R.string.title_featuredGroups), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) { + groups.forEach { group -> + FeaturedGroupRow( + group = group, + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button) { onOpen(group.chatId) }, + ) + } + } + } +} + +/** + * A group's picture, title and subtitle. [trailing] is the slot after the text: empty on a + * profile, a check in the picker. Tap handling is on [modifier], so each caller decides what a + * tap on the row means. + */ +@Composable +fun FeaturedGroupRow( + group: FeaturedGroupItem, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + ) { + ContactAvatar( + image = group.picture, + displayName = group.title, + access = BlobAccessContext.ChatProfile(group.chatId), + modifier = Modifier + .requiredSize(PictureSize) + .clip(RoundedCornerShape(CodeTheme.dimens.staticGrid.x3)), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1 / 2), + ) { + Text( + text = group.title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = group.subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + trailing() + } +} + +/** + * [groups] as the rows draw them. An untitled group reads "Group Chat", and a group with no + * description shows how many people are in it instead. + */ +@Composable +fun rememberFeaturedGroupItems(groups: List): List { + val untitled = stringResource(R.string.label_linkCard_untitledGroup) + val counts = groups.map { + pluralStringResource( + R.plurals.subtitle_chatMemberCount, + it.rosterSummary.memberCount.toInt(), + it.rosterSummary.memberCount.toString(), + ) + } + return remember(groups, counts, untitled) { + groups.mapIndexed { index, group -> + FeaturedGroupItem( + chatId = group.chatId, + title = group.title?.trim().takeUnless { it.isNullOrEmpty() } ?: untitled, + subtitle = group.description?.trim().takeUnless { it.isNullOrEmpty() } ?: counts[index], + picture = group.picture, + ) + } + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 71b37dad93..f6ca80d2b6 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -11,6 +11,7 @@ import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.chat.observeUnreadChatListCount import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController @@ -111,6 +112,7 @@ class RealSessionController @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, + private val featuredGroupsStore: FeaturedGroupsStore, private val blocklistCoordinator: BlocklistCoordinator, private val blobStorageCoordinator: BlobStorageCoordinator, private val mediaUrlResolver: MediaUrlResolver, @@ -204,6 +206,7 @@ class RealSessionController @Inject constructor( depositDelegate.cancelSweep() scope.launch { contactCoordinator.reset() } scope.launch { chatCoordinator.teardown() } + featuredGroupsStore.reset() // Blob download URLs are minted for the signed-in owner. scope.launch { mediaUrlResolver.reset() } stateHolder.reset() diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt index 735eb77174..e363776956 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt @@ -119,6 +119,7 @@ class SessionControllerEventRoutingTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + featuredGroupsStore = mockk(relaxed = true), blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 4a7ddccfbb..72210e9135 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -145,6 +145,7 @@ class SessionControllerGiftCardErrorTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + featuredGroupsStore = mockk(relaxed = true), blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt index fc326aa186..79bf239d92 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt @@ -149,6 +149,7 @@ class SessionControllerShareCheckTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + featuredGroupsStore = mockk(relaxed = true), blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), mediaUrlResolver = mockk(relaxed = true), From 4cf03c923745b11265062675e23147eafd08e504 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:09:36 -0400 Subject: [PATCH 2/4] feat(profile): pick favourite public groups from Edit Profile Edit Profile gets a Favorite Public Groups row that opens a picker over the featured groups plus the joined public ones, up to 10, saved in pick order. Public groups are filtered from the live group feed because isPrivate is not persisted. --- .../app/core/userprofile/UpdateProfileStep.kt | 5 + .../features/myaccount/build.gradle.kts | 1 + .../editprofile/EditProfileScreenContent.kt | 9 + .../editprofile/EditProfileViewModel.kt | 12 + .../editprofile/EditProfileViewModelTest.kt | 30 ++ .../features/user-profile/build.gradle.kts | 1 + .../userprofile/UserProfileSetupFlowScreen.kt | 4 + .../EditFeaturedGroupsScreen.kt | 190 +++++++++++ .../EditFeaturedGroupsViewModel.kt | 224 +++++++++++++ .../EditFeaturedGroupsViewModelTest.kt | 305 ++++++++++++++++++ 10 files changed, 781 insertions(+) create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModel.kt create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModelTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt index 0526039f0b..f355f0592e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -48,4 +48,9 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable { @Parcelize @Serializable object Bio : UpdateProfileStep + + /** The public groups the profile features, picked from the ones the user has joined. */ + @Parcelize + @Serializable + object FeaturedGroups : UpdateProfileStep } diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 59f5a1d8fb..3362ff2d55 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bill-customization")) implementation(project(":apps:flipcash:shared:blocklist")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt index 5527f290f6..33ba51630e 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -110,6 +111,14 @@ internal fun EditProfileScreenContent( placeholder = stringResource(R.string.placeholder_minimumToChat), onClick = { openStep(UpdateProfileStep.MinimumTip) }, ) + FieldCard( + title = stringResource(R.string.title_featuredGroups), + value = state.featuredGroupCount.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.value_featuredGroupCount, it, it.toString()) + }, + placeholder = stringResource(R.string.placeholder_featuredGroups), + onClick = { openStep(UpdateProfileStep.FeaturedGroups) }, + ) } } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt index a52551426f..44bb7d9e59 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt @@ -18,6 +18,7 @@ import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -47,6 +48,7 @@ internal class EditProfileViewModel @Inject constructor( userFlags: UserFlagsCoordinator, totalBalance: TotalBalanceProvider, tipPayments: TipPaymentDelegate, + featuredGroups: FeaturedGroupsStore, private val purchaseMethodController: PurchaseMethodController, private val analytics: FlipcashAnalytics, private val resources: ResourceHelper, @@ -67,6 +69,8 @@ internal class EditProfileViewModel @Inject constructor( val cover: MediaItem? = null, /** What others pay to open a chat, formatted. Null until it resolves. */ val minimumToChat: String? = null, + /** How many public groups the profile features; the row reads it from the session's list. */ + val featuredGroupCount: Int = 0, val usernameGate: UsernameGate = UsernameGate.Unlocked, val usernameMinimumBalance: String = "", ) @@ -74,6 +78,7 @@ internal class EditProfileViewModel @Inject constructor( internal sealed interface Event { data class OnProfileChanged(val profile: UserProfile) : Event data class OnMinimumToChatChanged(val minimumToChat: String?) : Event + data class OnFeaturedGroupCountChanged(val count: Int) : Event data class OnUsernameGateChanged(val gate: UsernameGate, val minimumBalance: String) : Event /** A field card's tap; the screen opens the step in the profile editor. */ @@ -101,6 +106,12 @@ internal class EditProfileViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) } .launchIn(viewModelScope) + featuredGroups.groups + .map { it.size } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnFeaturedGroupCountChanged(it)) } + .launchIn(viewModelScope) + combine( profile.map { it?.username to (it?.isUsernameAutoAssigned == true) }, userFlags.resolvedFlags.map { it.usernameMinBalance.effectiveValue }, @@ -180,6 +191,7 @@ internal class EditProfileViewModel @Inject constructor( } is Event.OnMinimumToChatChanged -> { state -> state.copy(minimumToChat = event.minimumToChat) } + is Event.OnFeaturedGroupCountChanged -> { state -> state.copy(featuredGroupCount = event.count) } is Event.OnUsernameGateChanged -> { state -> state.copy(usernameGate = event.gate, usernameMinimumBalance = event.minimumBalance) } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt index 7c14b727c6..232b9532f6 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt @@ -12,7 +12,11 @@ import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.app.userflags.UsernameGate import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.financial.Fiat @@ -33,6 +37,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.time.Instant import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -45,6 +50,7 @@ class EditProfileViewModelTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val purchaseMethodController = mockk(relaxed = true) + private val featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)) @Before @After @@ -64,6 +70,7 @@ class EditProfileViewModelTest { userFlags = userFlags, totalBalance = totalBalance, tipPayments = mockk(relaxed = true), + featuredGroups = featuredGroups, purchaseMethodController = purchaseMethodController, analytics = mockk(relaxed = true), resources = FakeResourceHelper(), @@ -196,4 +203,27 @@ class EditProfileViewModelTest { val state = reduce(EditProfileViewModel.Event.OnMinimumToChatChanged("$1.00"))(EditProfileViewModel.State()) assertEquals("$1.00", state.minimumToChat) } + + @Test + fun `the featured groups count follows the session list`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel() + advanceUntilIdle() + assertEquals(0, vm.stateFlow.value.featuredGroupCount) + + featuredGroups.replace(listOf(group("aa"), group("bb"))) + advanceUntilIdle() + assertEquals(2, vm.stateFlow.value.featuredGroupCount) + + featuredGroups.reset() + advanceUntilIdle() + assertEquals(0, vm.stateFlow.value.featuredGroupCount) + } + + private fun group(hex: String) = ChatMetadata( + chatId = ChatId(hex), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(0), + ) } diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index 15c5ad2dec..ecb4de4aca 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:blob")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:payments")) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index 2b7e985fbd..784115fa1c 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -11,6 +11,7 @@ import com.flipcash.app.core.ui.transitions.LocalHostEnterTransition import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.userprofile.internal.bio.EditBioScreen +import com.flipcash.app.userprofile.internal.featuredgroups.EditFeaturedGroupsScreen import com.flipcash.app.userprofile.internal.mintip.MinimumTipEntryScreen import com.flipcash.app.userprofile.internal.name.NameEntryScreen import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen @@ -85,6 +86,9 @@ private fun profileUpdateProvider( annotatedEntry { EditBioScreen() } + annotatedEntry { + EditFeaturedGroupsScreen() + } annotatedEntry { MinimumTipEntryScreen(isLastStep = route.steps.lastOrNull() == UpdateProfileStep.MinimumTip) } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt new file mode 100644 index 0000000000..9d05a17095 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt @@ -0,0 +1,190 @@ +package com.flipcash.app.userprofile.internal.featuredgroups + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.flipcash.shared.common.ui.profile.FeaturedGroupRow +import com.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SearchInput +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** A group past the limit is shown, but not offered, while the selection is full. */ +private const val DisabledAlpha = 0.4f + +@Composable +internal fun EditFeaturedGroupsScreen() { + val flowNavigator = rememberFlowNavigator() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column { + AppBarWithTitle( + title = stringResource(R.string.title_featuredGroupsPicker), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + EditFeaturedGroupsScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} + +@Composable +private fun EditFeaturedGroupsScreenContent( + state: EditFeaturedGroupsViewModel.State, + dispatchEvent: (EditFeaturedGroupsViewModel.Event) -> Unit, +) { + CodeScaffold( + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding( + top = CodeTheme.dimens.staticGrid.x3, + bottom = CodeTheme.dimens.staticGrid.x5, + ), + text = stringResource(R.string.action_save), + enabled = state.canSave, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) }, + ) + } + ) { padding -> + val loading = state.loadState == EditFeaturedGroupsViewModel.LoadState.Loading + val visible = state.visibleCandidates + val items = rememberFeaturedGroupItems(visible) + + Column(modifier = Modifier.padding(padding)) { + SearchInput( + state = state.queryState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + placeholder = stringResource(R.string.hint_searchPublicGroups), + ) + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = CodeTheme.colors.textSecondary, + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item(key = "count") { + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x4, + ), + text = stringResource( + R.string.label_featuredGroupsSelected, + state.selection.size.toString(), + FeaturedGroupsLimit.toString(), + ), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + items(items, key = { it.chatId.toString() }) { group -> + val selected = group.chatId in state.selection + val enabled = state.isEnabled(group.chatId) + FeaturedGroupRow( + group = group, + modifier = Modifier + .alpha(if (enabled) 1f else DisabledAlpha) + .clickable(enabled = enabled, role = Role.Checkbox) { + dispatchEvent(EditFeaturedGroupsViewModel.Event.Toggle(group.chatId)) + } + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x2, + ), + trailing = { + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, + ) + }, + ) + } + if (state.candidates.isNotEmpty()) { + item(key = "footer") { + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x4, + ), + text = stringResource(R.string.footer_featuredGroups), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + } + + // A failed read still draws the cached groups above, with Save held back. + val message = when { + state.loadState == EditFeaturedGroupsViewModel.LoadState.Failed -> + R.string.text_featuredGroupsLoadFailed + state.candidates.isEmpty() -> R.string.text_featuredGroupsEmpty + else -> null + } + if (message != null) { + Text( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.grid.x8), + text = stringResource(message), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModel.kt new file mode 100644 index 0000000000..9cfcff0fdb --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModel.kt @@ -0,0 +1,224 @@ +package com.flipcash.app.userprofile.internal.featuredgroups + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import com.flipcash.features.userprofile.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.SetFeaturedGroupsError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.FeaturedGroupsStore +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** The most groups a profile can feature. The server rejects more, so the picker stops at it. */ +internal const val FeaturedGroupsLimit = 10 + +/** + * Picks the public groups a profile features, in the order they are picked. + * + * The list opens on what the session already holds, so the rows are there before anything loads, + * and is then re-read: the server is the word on what is featured, and a group the user has left + * stays on the list so it can still be removed. Joined groups follow, public ones only, from a + * single read of the group feed. + */ +@HiltViewModel +internal class EditFeaturedGroupsViewModel @Inject constructor( + private val userManager: UserManager, + private val chatController: ChatController, + private val store: FeaturedGroupsStore, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State.seededWith(store.groups.value, resources.getString(R.string.label_linkCard_untitledGroup)), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + enum class LoadState { Loading, Loaded, Failed } + + data class State( + val queryState: TextFieldState = TextFieldState(), + val query: String = "", + val candidates: List = emptyList(), + /** The picked groups, in the order they were picked. */ + val selection: List = emptyList(), + /** The selection as the server last reported it, which is what a save has to differ from. */ + val saved: List = emptyList(), + val loadState: LoadState = LoadState.Loading, + val processingState: LoadingSuccessState = LoadingSuccessState(), + /** A group's title as the search and the rows read it, which is never blank. */ + val untitled: String = "", + ) { + val atLimit: Boolean get() = selection.size >= FeaturedGroupsLimit + + /** At the limit only a picked group can be tapped, so it can still be dropped. */ + fun isEnabled(chatId: ChatId): Boolean = chatId in selection || !atLimit + + /** Not until the featured list has been read, or a save could drop groups never seen. */ + val canSave: Boolean + get() = loadState == LoadState.Loaded && processingState.isIdle && selection != saved + + /** The candidates the query leaves, matched on title alone, ignoring case and outer spaces. */ + val visibleCandidates: List + get() { + val needle = query.trim() + if (needle.isEmpty()) return candidates + return candidates.filter { titleOf(it).contains(needle, ignoreCase = true) } + } + + private fun titleOf(chat: ChatMetadata) = + chat.title?.trim().takeUnless { it.isNullOrEmpty() } ?: untitled + + companion object { + fun seededWith(featured: List, untitled: String) = State( + candidates = featured, + selection = featured.map { it.chatId }, + saved = featured.map { it.chatId }, + untitled = untitled, + ) + } + } + + sealed interface Event { + data class Toggle(val chatId: ChatId) : Event + data class OnQueryChanged(val query: String) : Event + + /** The featured list as the server has it: adopted by a selection nobody has touched. */ + data class OnFeaturedRead(val featured: List) : Event + data class OnCandidatesLoaded(val candidates: List) : Event + data object OnLoadFailed : Event + data object Save : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + data object OnSaved : Event + } + + init { + snapshotFlow { stateFlow.value.queryState.text.toString() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnQueryChanged(it)) } + .launchIn(viewModelScope) + + viewModelScope.launch { loadCandidates() } + + eventFlow + .filterIsInstance() + .onEach { save() } + .launchIn(viewModelScope) + } + + private suspend fun loadCandidates() { + val username = userManager.state.value.userProfile?.username + // No handle means nothing is featured and nothing to read. + if (!username.isNullOrBlank() && !store.load(username)) { + dispatchEvent(Event.OnLoadFailed) + return + } + val featured = if (username.isNullOrBlank()) emptyList() else store.groups.value + dispatchEvent(Event.OnFeaturedRead(featured.map { it.chatId })) + + // A failed read leaves only the featured groups on offer rather than an error. + val joined = chatController.getGroupChatFeed().getOrNull()?.chats.orEmpty() + val featuredIds = featured.map { it.chatId }.toSet() + dispatchEvent( + Event.OnCandidatesLoaded( + featured + joined.filter { + it.type == ChatType.GROUP && !it.isPrivate && it.chatId !in featuredIds + } + ) + ) + } + + private fun save() { + val state = stateFlow.value + if (!state.canSave) return + viewModelScope.launch { + dispatchEvent(Event.UpdateProcessingState(loading = true)) + chatController.setFeaturedGroups(state.selection) + .onSuccess { groups -> + store.replace(groups) + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnSaved) + dispatchEvent(Event.UpdateProcessingState()) + } + .onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + // The selection stays so the user can fix it rather than rebuild it. + if (cause is SetFeaturedGroupsError.Denied) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_featuredGroupsPrivate), + message = resources.getString(R.string.error_description_featuredGroupsPrivate), + ) + } else { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_featuredGroupsSaveFailed), + message = resources.getString(R.string.error_description_featuredGroupsSaveFailed), + ) + } + } + } + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Toggle -> { state -> + when { + event.chatId in state.selection -> + state.copy(selection = state.selection - event.chatId) + state.atLimit -> state + else -> state.copy(selection = state.selection + event.chatId) + } + } + + is Event.OnQueryChanged -> { state -> state.copy(query = event.query) } + + is Event.OnFeaturedRead -> { state -> + // A pick made while the read was out stands. + val untouched = state.selection == state.saved + state.copy( + saved = event.featured, + selection = if (untouched) event.featured else state.selection, + ) + } + + is Event.OnCandidatesLoaded -> { state -> + state.copy(candidates = event.candidates, loadState = LoadState.Loaded) + } + + Event.OnLoadFailed -> { state -> state.copy(loadState = LoadState.Failed) } + + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + + Event.Save, + Event.OnSaved -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModelTest.kt new file mode 100644 index 0000000000..d4ca3d1c62 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsViewModelTest.kt @@ -0,0 +1,305 @@ +package com.flipcash.app.userprofile.internal.featuredgroups + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.SetFeaturedGroupsError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.FeaturedGroupsStore +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class EditFeaturedGroupsViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatController = mockk() + private val store = FeaturedGroupsStore(chatController) + private val userManager = mockk(relaxed = true) + + private val a = ChatId("aa") + private val b = ChatId("bb") + private val c = ChatId("cc") + + @Before + @After + fun clearAlerts() = BottomBarManager.clear() + + private fun group( + hex: String, + type: ChatType = ChatType.GROUP, + isPrivate: Boolean = false, + title: String? = hex, + ) = ChatMetadata( + chatId = ChatId(hex), + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(0), + title = title, + isPrivate = isPrivate, + ) + + private fun feed(vararg chats: ChatMetadata) = Result.success( + ChatFeedPage(chats = chats.toList(), pagingToken = null, hasMore = false) + ) + + /** [featured] is what the server returns when the screen re-reads; [joined] is the group feed. */ + private fun TestScope.viewModel( + cached: List = emptyList(), + featured: Result> = Result.success(cached), + joined: Result = feed(), + ): EditFeaturedGroupsViewModel { + store.replace(cached) + every { userManager.state } returns MutableStateFlow( + UserManager.State(userProfile = UserProfile.Empty.copy(username = "me")) + ) + coEvery { chatController.getFeaturedGroups(any()) } returns featured + coEvery { chatController.getGroupChatFeed(any()) } returns joined + return EditFeaturedGroupsViewModel( + userManager = userManager, + chatController = chatController, + store = store, + resources = FakeResourceHelper(), + dispatchers = TestDispatchers(testScheduler), + ) + } + + private val EditFeaturedGroupsViewModel.state get() = stateFlow.value + private fun EditFeaturedGroupsViewModel.toggle(chatId: ChatId) = + dispatchEvent(EditFeaturedGroupsViewModel.Event.Toggle(chatId)) + + private fun EditFeaturedGroupsViewModel.search(text: String) { + state.queryState.setTextAndPlaceCursorAtEnd(text) + Snapshot.sendApplyNotifications() + } + + @Test + fun `candidates are the featured groups then the joined public ones`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel( + cached = listOf(group("bb"), group("aa")), + joined = feed( + group("aa"), + group("cc"), + group("dd", isPrivate = true), + group("ee", type = ChatType.CONTACT_DM), + ), + ) + advanceUntilIdle() + + assertEquals(listOf(b, a, c), vm.state.candidates.map { it.chatId }) + assertEquals(listOf(b, a), vm.state.selection) + assertEquals(EditFeaturedGroupsViewModel.LoadState.Loaded, vm.state.loadState) + } + + @Test + fun `a featured group the user has left stays on the list`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(cached = listOf(group("aa")), joined = feed(group("cc"))) + advanceUntilIdle() + + assertEquals(listOf(a, c), vm.state.candidates.map { it.chatId }) + assertEquals(listOf(a), vm.state.selection) + } + + @Test + fun `new picks go on the end and a removal keeps the order of the rest`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(joined = feed(group("aa"), group("bb"), group("cc"))) + advanceUntilIdle() + + vm.toggle(b) + vm.toggle(a) + vm.toggle(c) + assertEquals(listOf(b, a, c), vm.state.selection) + + vm.toggle(a) + assertEquals(listOf(b, c), vm.state.selection) + } + + @Test + fun `selection stops at the limit and a picked group can still be dropped`() = runTest(mainCoroutineRule.dispatcher) { + val all = (0..10).map { group("%02x".format(it)) } + val vm = viewModel(joined = feed(*all.toTypedArray())) + advanceUntilIdle() + + all.take(FeaturedGroupsLimit).forEach { vm.toggle(it.chatId) } + assertTrue(vm.state.atLimit) + + val eleventh = all.last().chatId + assertFalse(vm.state.isEnabled(eleventh)) + vm.toggle(eleventh) + assertEquals(FeaturedGroupsLimit, vm.state.selection.size) + assertFalse(eleventh in vm.state.selection) + + val first = all.first().chatId + assertTrue(vm.state.isEnabled(first)) + vm.toggle(first) + assertEquals(FeaturedGroupsLimit - 1, vm.state.selection.size) + } + + @Test + fun `save needs a real change`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(joined = feed(group("aa"))) + advanceUntilIdle() + assertFalse(vm.state.canSave) + + vm.toggle(a) + assertTrue(vm.state.canSave) + + vm.toggle(a) + assertFalse(vm.state.canSave) + } + + @Test + fun `a pick made before the load finishes survives it`() { + val reduce = EditFeaturedGroupsViewModel.updateStateForEvent + val opened = EditFeaturedGroupsViewModel.State.seededWith(emptyList(), "Group Chat") + + val picked = reduce(EditFeaturedGroupsViewModel.Event.Toggle(a))(opened) + assertFalse(picked.canSave) + + val read = reduce(EditFeaturedGroupsViewModel.Event.OnFeaturedRead(listOf(b)))(picked) + val loaded = reduce(EditFeaturedGroupsViewModel.Event.OnCandidatesLoaded(emptyList()))(read) + + assertEquals(listOf(a), loaded.selection) + assertEquals(listOf(b), loaded.saved) + assertTrue(loaded.canSave) + } + + @Test + fun `a failed read of the featured list blocks saving`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel( + cached = listOf(group("aa")), + featured = Result.failure(IllegalStateException("offline")), + ) + advanceUntilIdle() + + assertEquals(EditFeaturedGroupsViewModel.LoadState.Failed, vm.state.loadState) + vm.toggle(b) + assertFalse(vm.state.canSave) + } + + @Test + fun `an untouched selection follows the server over a stale cache`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel( + cached = listOf(group("aa")), + featured = Result.success(listOf(group("bb"), group("cc"))), + ) + advanceUntilIdle() + + assertEquals(listOf(b, c), vm.state.selection) + assertFalse(vm.state.canSave) + } + + @Test + fun `saving sends the picked order and keeps what the server returns`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(joined = feed(group("aa"), group("bb"))) + advanceUntilIdle() + val returned = listOf(group("bb"), group("aa")) + coEvery { chatController.setFeaturedGroups(any()) } returns Result.success(returned) + + vm.toggle(b) + vm.toggle(a) + vm.dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) + advanceUntilIdle() + + coVerify { chatController.setFeaturedGroups(listOf(b, a)) } + assertEquals(returned, store.groups.value) + } + + @Test + fun `clearing every group saves an empty list`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(cached = listOf(group("aa"))) + advanceUntilIdle() + coEvery { chatController.setFeaturedGroups(any()) } returns Result.success(emptyList()) + + vm.toggle(a) + assertTrue(vm.state.canSave) + vm.dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) + advanceUntilIdle() + + coVerify { chatController.setFeaturedGroups(emptyList()) } + } + + @Test + fun `a denied save explains the private group and keeps the selection`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(joined = feed(group("aa"))) + advanceUntilIdle() + coEvery { chatController.setFeaturedGroups(any()) } returns + Result.failure(SetFeaturedGroupsError.Denied()) + + vm.toggle(a) + vm.dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) + advanceUntilIdle() + + assertEquals( + "error_title_featuredGroupsPrivate", + BottomBarManager.messages.value.firstOrNull()?.title, + ) + assertEquals(listOf(a), vm.state.selection) + assertTrue(vm.state.processingState.isIdle) + } + + @Test + fun `any other failed save shows the generic error`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(joined = feed(group("aa"))) + advanceUntilIdle() + coEvery { chatController.setFeaturedGroups(any()) } returns + Result.failure(SetFeaturedGroupsError.NotFound()) + + vm.toggle(a) + vm.dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) + advanceUntilIdle() + + assertEquals( + "error_title_featuredGroupsSaveFailed", + BottomBarManager.messages.value.firstOrNull()?.title, + ) + assertTrue(vm.state.processingState.isIdle) + } + + @Test + fun `search matches titles ignoring case and outer spaces`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel( + joined = feed( + group("aa", title = "Solana Devs"), + group("bb", title = "Cooking"), + ), + ) + advanceUntilIdle() + + vm.search(" SOLANA ") + advanceUntilIdle() + assertEquals(listOf(a), vm.state.visibleCandidates.map { it.chatId }) + + vm.search(" ") + advanceUntilIdle() + assertEquals(2, vm.state.visibleCandidates.size) + } +} From 5d2513c34f6d0402614ef97894ddf1d3692cb06c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:44:01 -0400 Subject: [PATCH 3/4] feat(profile): scroll favourite groups under frosted search and Save The list runs under the search header and the Save button, which use floatingGlass like the rest of the app. Rows fade in under search and fade out with verticalScrollStateGradient from Save's top edge to the bottom of the screen, matching iOS's scroll-edge bar. Group pictures are clipped to circles, as iOS draws them. --- .../features/user-profile/build.gradle.kts | 1 + .../EditFeaturedGroupsScreen.kt | 304 +++++++++++------- .../ui/profile/FeaturedGroupsSection.kt | 4 +- 3 files changed, 197 insertions(+), 112 deletions(-) diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index ecb4de4aca..07815031be 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { testImplementation(libs.mockito.kotlin) implementation(libs.bundles.kotlinx.serialization) + implementation(libs.bundles.haze) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt index 9d05a17095..b1eae7c512 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt @@ -1,9 +1,24 @@ package com.flipcash.app.userprofile.internal.featuredgroups import androidx.compose.foundation.Image +import com.getcode.ui.core.verticalScrollStateGradient +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.WindowInsets +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.draw.drawBehind +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.navigationBarsPadding @@ -18,10 +33,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.userprofile.UpdateProfileResult @@ -34,7 +52,6 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SearchInput import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -42,6 +59,16 @@ import kotlinx.coroutines.flow.onEach /** A group past the limit is shown, but not offered, while the selection is full. */ private const val DisabledAlpha = 0.4f +/** How far below the search field the header's fade runs. */ +private val ScrimTail = 32.dp + +/** How far up from the header's bottom the fade starts: about half the search field. */ +private val SearchFadeInset = 24.dp + +/** Save's height: the list starts to fade from the button's top edge. */ +private val FadeAboveSaveBottom = 60.dp + + @Composable internal fun EditFeaturedGroupsScreen() { val flowNavigator = rememberFlowNavigator() @@ -49,14 +76,11 @@ internal fun EditFeaturedGroupsScreen() { val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - Column { - AppBarWithTitle( - title = stringResource(R.string.title_featuredGroupsPicker), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { flowNavigator.back() }, - ) - EditFeaturedGroupsScreenContent(state, viewModel::dispatchEvent) - } + EditFeaturedGroupsScreenContent( + state = state, + onBack = { flowNavigator.back() }, + dispatchEvent = viewModel::dispatchEvent, + ) LaunchedEffect(viewModel) { viewModel.eventFlow @@ -69,10 +93,165 @@ internal fun EditFeaturedGroupsScreen() { @Composable private fun EditFeaturedGroupsScreenContent( state: EditFeaturedGroupsViewModel.State, + onBack: () -> Unit, dispatchEvent: (EditFeaturedGroupsViewModel.Event) -> Unit, ) { - CodeScaffold( - bottomBar = { + val density = LocalDensity.current + val scrimColor = CodeTheme.colors.background + var headerHeight by remember { mutableStateOf(0.dp) } + var barHeight by remember { mutableStateOf(0.dp) } + val hazeState = rememberHazeState() + val listState = rememberLazyListState() + val saveBottomPadding = CodeTheme.dimens.staticGrid.x5 + val navBottom = WindowInsets.navigationBars.getBottom(density).toFloat() + + val loading = state.loadState == EditFeaturedGroupsViewModel.LoadState.Loading + val items = rememberFeaturedGroupItems(state.visibleCandidates) + + // The list runs under the header and Save; search and Save frost what passes behind them. + Box( + modifier = Modifier + .fillMaxSize() + .background(scrimColor) + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = CodeTheme.colors.textSecondary, + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + // Rows pass behind the Save glass and fade out from its top edge to the bottom of the screen. + .verticalScrollStateGradient( + scrollState = listState, + showAtStart = false, + fadeSize = FadeAboveSaveBottom + saveBottomPadding + + with(density) { navBottom.toDp() }, + ) + .hazeSource(hazeState), + contentPadding = PaddingValues(top = headerHeight, bottom = barHeight), + ) { + item(key = "count") { + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x4, + ), + text = stringResource( + R.string.label_featuredGroupsSelected, + state.selection.size.toString(), + FeaturedGroupsLimit.toString(), + ), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + items(items, key = { it.chatId.toString() }) { group -> + val selected = group.chatId in state.selection + val enabled = state.isEnabled(group.chatId) + FeaturedGroupRow( + group = group, + modifier = Modifier + .alpha(if (enabled) 1f else DisabledAlpha) + .clickable(enabled = enabled, role = Role.Checkbox) { + dispatchEvent(EditFeaturedGroupsViewModel.Event.Toggle(group.chatId)) + } + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x2, + ), + trailing = { + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, + ) + }, + ) + } + if (state.candidates.isNotEmpty()) { + item(key = "footer") { + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.staticGrid.x4, + ), + text = stringResource(R.string.footer_featuredGroups), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + } + + val message = when { + state.loadState == EditFeaturedGroupsViewModel.LoadState.Failed -> + R.string.text_featuredGroupsLoadFailed + state.candidates.isEmpty() -> R.string.text_featuredGroupsEmpty + else -> null + } + if (message != null) { + Text( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.grid.x8), + text = stringResource(message), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + // Solid down to the middle of the search field, then fading out a little below it, + // so rows fade in under the glass rather than meeting a hard edge. + .drawBehind { + val solidEnd = size.height - SearchFadeInset.toPx() + val fadeEnd = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to scrimColor, + solidEnd / fadeEnd to scrimColor, + 1f to Color.Transparent, + startY = 0f, + endY = fadeEnd, + ), + size = size.copy(height = fadeEnd), + ) + } + .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_featuredGroupsPicker), + titleAlignment = Alignment.CenterHorizontally, + hazeState = hazeState, + onBackIconClicked = onBack, + ) + SearchInput( + state = state.queryState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x2) + .floatingGlass(hazeState), + placeholder = stringResource(R.string.hint_searchPublicGroups), + ) + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { barHeight = with(density) { it.height.toDp() } }, + ) { CodeButton( modifier = Modifier .fillMaxWidth() @@ -80,8 +259,9 @@ private fun EditFeaturedGroupsScreenContent( .padding(horizontal = CodeTheme.dimens.inset) .padding( top = CodeTheme.dimens.staticGrid.x3, - bottom = CodeTheme.dimens.staticGrid.x5, - ), + bottom = saveBottomPadding, + ) + .floatingGlass(hazeState, CodeTheme.shapes.small), text = stringResource(R.string.action_save), enabled = state.canSave, isLoading = state.processingState.loading, @@ -89,102 +269,6 @@ private fun EditFeaturedGroupsScreenContent( onClick = { dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) }, ) } - ) { padding -> - val loading = state.loadState == EditFeaturedGroupsViewModel.LoadState.Loading - val visible = state.visibleCandidates - val items = rememberFeaturedGroupItems(visible) - - Column(modifier = Modifier.padding(padding)) { - SearchInput( - state = state.queryState, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - placeholder = stringResource(R.string.hint_searchPublicGroups), - ) - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = CodeTheme.colors.textSecondary, - ) - } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - item(key = "count") { - Text( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.staticGrid.x4, - ), - text = stringResource( - R.string.label_featuredGroupsSelected, - state.selection.size.toString(), - FeaturedGroupsLimit.toString(), - ), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - ) - } - items(items, key = { it.chatId.toString() }) { group -> - val selected = group.chatId in state.selection - val enabled = state.isEnabled(group.chatId) - FeaturedGroupRow( - group = group, - modifier = Modifier - .alpha(if (enabled) 1f else DisabledAlpha) - .clickable(enabled = enabled, role = Role.Checkbox) { - dispatchEvent(EditFeaturedGroupsViewModel.Event.Toggle(group.chatId)) - } - .padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.staticGrid.x2, - ), - trailing = { - Image( - painter = painterResource( - if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked - ), - contentDescription = null, - ) - }, - ) - } - if (state.candidates.isNotEmpty()) { - item(key = "footer") { - Text( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.staticGrid.x4, - ), - text = stringResource(R.string.footer_featuredGroups), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - } - } - - // A failed read still draws the cached groups above, with Save held back. - val message = when { - state.loadState == EditFeaturedGroupsViewModel.LoadState.Failed -> - R.string.text_featuredGroupsLoadFailed - state.candidates.isEmpty() -> R.string.text_featuredGroupsEmpty - else -> null - } - if (message != null) { - Text( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = CodeTheme.dimens.grid.x8), - text = stringResource(message), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } - } - } - } } } + diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt index 60b73e4cd5..2de1207e86 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -98,7 +98,7 @@ fun FeaturedGroupRow( access = BlobAccessContext.ChatProfile(group.chatId), modifier = Modifier .requiredSize(PictureSize) - .clip(RoundedCornerShape(CodeTheme.dimens.staticGrid.x3)), + .clip(CircleShape), ) Column( modifier = Modifier.weight(1f), From 9a2b8be2d9092d02cfdfbd5ed07e1429c5eacdc7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:44:02 -0400 Subject: [PATCH 4/4] fix(copy): capitalise To in Edit Profile's Minimum To Chat --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 585c8c363b..8c8497f259 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1270,7 +1270,7 @@ starting a conversation. Distinct from the send-side strings above, which are about a tip in flight. The floor is explained in prose under the keypad and enforced by keeping the save button inert, so there is no below-minimum error to show. --> - Minimum to Chat + Minimum To Chat Choose how much someone must send you to start chatting. You can send it back if the conversation is worthwhile. Something Went Wrong We were unable to save your minimum tip. Please try again