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 0526039f0..f355f0592 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/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 4bd194596..8c8497f25 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
@@ -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 d16b3fbd7..9ae1e2ace 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 773af40b5..1ef8b4c96 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 3bede68c7..ce7988bbb 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 d5f5ba040..97f40c3e2 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 f7a9d0d15..bd5277e08 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 4f2bdbd0e..b28daa0f4 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/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts
index 59f5a1d8f..3362ff2d5 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 5527f290f..33ba51630 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 a52551426..44bb7d9e5 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 7c14b727c..232b9532f 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 15c5ad2de..07815031b 100644
--- a/apps/flipcash/features/user-profile/build.gradle.kts
+++ b/apps/flipcash/features/user-profile/build.gradle.kts
@@ -13,11 +13,13 @@ 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"))
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 2b7e985fb..784115fa1 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 000000000..b1eae7c51
--- /dev/null
+++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/featuredgroups/EditFeaturedGroupsScreen.kt
@@ -0,0 +1,274 @@
+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
+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.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
+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 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
+
+/** 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()
+
+ val viewModel = hiltViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+
+ EditFeaturedGroupsScreenContent(
+ state = state,
+ onBack = { flowNavigator.back() },
+ dispatchEvent = viewModel::dispatchEvent,
+ )
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .onEach { flowNavigator.proceed() }
+ .launchIn(this)
+ }
+}
+
+@Composable
+private fun EditFeaturedGroupsScreenContent(
+ state: EditFeaturedGroupsViewModel.State,
+ onBack: () -> Unit,
+ dispatchEvent: (EditFeaturedGroupsViewModel.Event) -> Unit,
+) {
+ 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()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(
+ top = CodeTheme.dimens.staticGrid.x3,
+ bottom = saveBottomPadding,
+ )
+ .floatingGlass(hazeState, CodeTheme.shapes.small),
+ text = stringResource(R.string.action_save),
+ enabled = state.canSave,
+ isLoading = state.processingState.loading,
+ isSuccess = state.processingState.success,
+ onClick = { dispatchEvent(EditFeaturedGroupsViewModel.Event.Save) },
+ )
+ }
+ }
+}
+
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 000000000..9cfcff0fd
--- /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 000000000..d4ca3d1c6
--- /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)
+ }
+}
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 000000000..d32b36f31
--- /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 000000000..bcdc25872
--- /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 000000000..2de1207e8
--- /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.CircleShape
+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(CircleShape),
+ )
+ 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 71b37dad9..f6ca80d2b 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 735eb7717..e36377695 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 4a7ddccfb..72210e913 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 fc326aa18..79bf239d9 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),