From 9b169ebac52ed286afd3446f21fe85d81332ce19 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 10:25:16 -0500 Subject: [PATCH 1/3] refactor(chat-list): move the chat list out of features/tipping into features/chat-list New KMP module with the chat list, archived list, their view models and row/reveal helpers, still all in androidMain. Strings and the empty-state drawable move from R to the Compose resources core already generates. hostedRequests stays in tipping, its only caller. --- apps/flipcash/app/build.gradle.kts | 1 + .../features/chat-list/build.gradle.kts | 68 +++++++++++++++++++ .../app/tipping/ChatsTabLayoutTest.kt | 0 .../internal/ChatsViewModelArchiveTest.kt | 3 +- .../internal/ChatsViewModelRatesTest.kt | 3 +- .../internal/ChatsViewModelStateTest.kt | 0 .../app/tipping/internal/ChipRevealTest.kt | 0 .../app/tipping/internal/ChipSettleTest.kt | 0 .../tipping/internal/JoinRequestRowTest.kt | 0 .../internal/TipChatRowScreenshotTest.kt | 0 .../app/tipping/ArchivedChatsScreen.kt | 13 ++-- .../com/flipcash/app/tipping/ChatsScreen.kt | 63 ++++++++++------- .../internal/ArchivedChatsViewModel.kt | 0 .../app/tipping/internal/ChatsViewModel.kt | 11 --- .../app/tipping/internal/ChipReveal.kt | 0 .../tipping/internal/ConversationMapping.kt | 0 .../tipping/internal/components/TipChatRow.kt | 15 ++-- .../app/tipping/internal/HostedRequests.kt | 15 ++++ settings.gradle.kts | 1 + 19 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 apps/flipcash/features/chat-list/build.gradle.kts rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt (100%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt (97%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt (98%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt (100%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt (100%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt (100%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt (100%) rename apps/flipcash/features/{tipping/src/test => chat-list/src/androidHostTest}/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt (100%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt (92%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/ChatsScreen.kt (90%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt (100%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt (95%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt (100%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt (100%) rename apps/flipcash/features/{tipping/src/main => chat-list/src/androidMain}/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt (92%) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index a9d947f472..49e3992fea 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -323,6 +323,7 @@ dependencies { implementation(project(":apps:flipcash:features:search")) implementation(project(":apps:flipcash:features:user-profile")) implementation(project(":apps:flipcash:features:userflags")) + implementation(project(":apps:flipcash:features:chat-list")) implementation(project(":apps:flipcash:features:tipping")) implementation(project(":libs:crypto:solana")) diff --git a/apps/flipcash/features/chat-list/build.gradle.kts b/apps/flipcash/features/chat-list/build.gradle.kts new file mode 100644 index 0000000000..978e03ad2a --- /dev/null +++ b/apps/flipcash/features/chat-list/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.flipcash.kmp.app.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.ksp) +} + +kotlin { + android { + namespace = "${Gradle.flipcashNamespace}.features.chatlist" + } + + sourceSets { + commonMain.dependencies { + implementation(project(":apps:flipcash:core")) + implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":apps:flipcash:shared:chat-ui")) + implementation(project(":apps:flipcash:shared:common-ui")) + implementation(project(":apps:flipcash:shared:theme")) + implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":services:flipcash")) + implementation(project(":services:opencode")) + implementation(project(":libs:coroutines")) + implementation(project(":ui:components")) + implementation(project(":ui:core")) + implementation(project(":ui:navigation")) + implementation(project(":ui:resources")) + implementation(project(":ui:theme")) + + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.animation) + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.material) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.jetbrains.compose.components.resources) + implementation(libs.jetbrains.compose.ui.tooling.preview) + implementation(libs.bundles.haze) + implementation(libs.javax.inject) + } + androidMain.dependencies { + // What flipcash.android.feature supplied before the move to KMP. + implementation(libs.bundles.hilt) + implementation(project.dependencies.platform(libs.compose.bom)) + implementation(libs.bundles.compose) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockk) + } + getByName("androidHostTest").dependencies { + implementation(kotlin("test")) + implementation(libs.bundles.unit.testing) + implementation(libs.robolectric) + implementation(project(":ui:resources:test-fixtures")) + implementation(project(":libs:test-utils")) + } + } +} + +dependencies { + add("kspAndroid", libs.hilt.android.compiler) + add("kspAndroid", libs.hilt.compiler) + add("kspJvm", libs.dagger.compiler) + add("jvmMainImplementation", libs.dagger) +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatsTabLayoutTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt similarity index 97% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index 6b1e64517f..02041a755c 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -3,7 +3,6 @@ package com.flipcash.app.tipping.internal import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType @@ -75,7 +74,7 @@ class ChatsViewModelArchiveTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(R::class.java), + resources = FakeResourceHelper(), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt similarity index 98% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt index 7fa71bced8..2079e9a33d 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -3,7 +3,6 @@ package com.flipcash.app.tipping.internal import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata @@ -99,7 +98,7 @@ class ChatsViewModelRatesTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(R::class.java), + resources = FakeResourceHelper(), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt similarity index 100% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt rename to apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt similarity index 92% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt index 26439d64e6..2f23cad67d 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -1,5 +1,9 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.content_description_unarchiveChat +import com.flipcash.core.resources.title_archived +import com.flipcash.core.resources.title_noArchivedChats import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -16,7 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics @@ -28,7 +32,6 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.data.isLoaded import com.flipcash.app.tipping.internal.ArchivedChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.navigation.core.LocalCodeNavigator @@ -73,7 +76,7 @@ private fun ArchivedChatsContent( CodeScaffold( topBar = { AppBarWithTitle( - title = stringResource(R.string.title_archived), + title = stringResource(Res.string.title_archived), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = onBack, ) @@ -96,7 +99,7 @@ private fun ArchivedChatsContent( ) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.title_noArchivedChats), + text = stringResource(Res.string.title_noArchivedChats), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -131,7 +134,7 @@ private fun UnarchiveSwipeRow( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val label = stringResource(R.string.content_description_unarchiveChat) + val label = stringResource(Res.string.content_description_unarchiveChat) SwipeActionRow( actions = listOf( SwipeAction( diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt similarity index 90% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index a955a3dfcd..026e64792b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,10 +1,27 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_undo +import com.flipcash.core.resources.content_description_archiveChat +import com.flipcash.core.resources.content_description_archivedUnread +import com.flipcash.core.resources.content_description_changeMute +import com.flipcash.core.resources.content_description_muteChat +import com.flipcash.core.resources.description_noChatsYet +import com.flipcash.core.resources.ic_bubble_outline +import com.flipcash.core.resources.message_chatArchived +import com.flipcash.core.resources.title_allChatsArchived +import com.flipcash.core.resources.title_archived +import com.flipcash.core.resources.title_filterAll +import com.flipcash.core.resources.title_filterGroups +import com.flipcash.core.resources.title_filterUnread +import com.flipcash.core.resources.title_noChatsYet +import com.flipcash.core.resources.title_noGroupChats +import com.flipcash.core.resources.title_noUnreadChats +import com.flipcash.core.resources.title_tabChat import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.ScrollableDefaults @@ -62,8 +79,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -76,7 +94,6 @@ import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.tipping.internal.ChatsViewModel import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.features.tipping.R import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.core.LocalCodeNavigator @@ -149,8 +166,8 @@ fun ChatsScreen() { val toasts = LocalFloatingToastHost.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current - val archivedMessage = stringResource(R.string.message_chatArchived) - val undoLabel = stringResource(R.string.action_undo) + val archivedMessage = stringResource(Res.string.message_chatArchived) + val undoLabel = stringResource(Res.string.action_undo) val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() val hazeState = rememberHazeState() @@ -161,7 +178,7 @@ fun ChatsScreen() { barPlacement = ScaffoldBarPlacement.Overlay, topBar = { AppBarWithTitle( - title = stringResource(R.string.title_tabChat), + title = stringResource(Res.string.title_tabChat), // Centred rather than flush-start: an empty leading slot reserves no width, so a // Start title sits at the inset and reads as off-centre against the Add button. titleAlignment = Alignment.CenterHorizontally, @@ -264,9 +281,9 @@ fun ChatsScreen() { .then(parkFiller.tracked("empty_filter")) .fillParentMaxSize(), message = when (state.filter) { - ChatListFilter.Unread -> R.string.title_noUnreadChats - ChatListFilter.Groups -> R.string.title_noGroupChats - ChatListFilter.All -> R.string.title_allChatsArchived + ChatListFilter.Unread -> Res.string.title_noUnreadChats + ChatListFilter.Groups -> Res.string.title_noGroupChats + ChatListFilter.All -> Res.string.title_allChatsArchived }, ) } @@ -332,13 +349,13 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { ) { Image( modifier = Modifier.size(CodeTheme.dimens.grid.x16), - painter = painterResource(R.drawable.ic_bubble_outline), + painter = painterResource(Res.drawable.ic_bubble_outline), contentDescription = null, colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), ) Text( - text = stringResource(R.string.title_noChatsYet), + text = stringResource(Res.string.title_noChatsYet), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, textAlign = TextAlign.Center, @@ -346,7 +363,7 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { Text( modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(R.string.description_noChatsYet), + text = stringResource(Res.string.description_noChatsYet), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, @@ -409,10 +426,10 @@ private fun ChatSwipeRow( content: @Composable () -> Unit, ) { val muteLabel = stringResource( - if (isMuted) R.string.content_description_changeMute - else R.string.content_description_muteChat + if (isMuted) Res.string.content_description_changeMute + else Res.string.content_description_muteChat ) - val archiveLabel = stringResource(R.string.content_description_archiveChat) + val archiveLabel = stringResource(Res.string.content_description_archiveChat) SwipeActionRow( actions = listOf( SwipeAction( @@ -478,18 +495,18 @@ private fun ChatFilterRow( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { FilterChip( - label = stringResource(R.string.title_filterAll), + label = stringResource(Res.string.title_filterAll), selected = selected == ChatListFilter.All, onClick = { onSelect(ChatListFilter.All) }, ) FilterChip( - label = stringResource(R.string.title_filterUnread), + label = stringResource(Res.string.title_filterUnread), selected = selected == ChatListFilter.Unread, onClick = { onSelect(ChatListFilter.Unread) }, count = unreadCount, ) FilterChip( - label = stringResource(R.string.title_filterGroups), + label = stringResource(Res.string.title_filterGroups), selected = selected == ChatListFilter.Groups, onClick = { onSelect(ChatListFilter.Groups) }, count = groupsCount, @@ -504,9 +521,9 @@ private fun ChatFilterRow( @Composable private fun ArchivedRow(count: Int, onClick: () -> Unit) { val description = if (count > 0) { - stringResource(R.string.content_description_archivedUnread, count) + stringResource(Res.string.content_description_archivedUnread, count) } else { - stringResource(R.string.title_archived) + stringResource(Res.string.title_archived) } val color = CodeTheme.colors.textSecondary Row( @@ -538,7 +555,7 @@ private fun ArchivedRow(count: Int, onClick: () -> Unit) { } Text( modifier = Modifier.weight(1f), - text = stringResource(R.string.title_archived), + text = stringResource(Res.string.title_archived), style = CodeTheme.typography.textMedium, color = color, ) @@ -559,7 +576,7 @@ private fun ArchivedRow(count: Int, onClick: () -> Unit) { /** A filter with nothing under it, centred in the space it is given (the list viewport). */ @Composable -private fun EmptyFilterState(@StringRes message: Int, modifier: Modifier = Modifier) { +private fun EmptyFilterState(message: StringResource, modifier: Modifier = Modifier) { Box(modifier = modifier, contentAlignment = Alignment.Center) { Text( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt similarity index 100% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt similarity index 95% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index cef14e4572..8066975e00 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -220,14 +220,3 @@ internal fun List.inOrderOf(ids: List): List id to index } return sortedBy { rank[it.chatId.toString()] ?: Int.MAX_VALUE } } - -/** - * The hosted groups with someone waiting, each with its waiting list, the group whose newest - * request is the most recent first. - */ -internal fun hostedRequests( - hosted: Collection, - lobbies: Map>, -): List>> = hosted - .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } - .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt similarity index 100% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt similarity index 100% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt similarity index 92% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt rename to apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index af89468733..123b0ff80d 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,5 +1,9 @@ package com.flipcash.app.tipping.internal.components +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.subtitle_peopleWantToJoin +import com.flipcash.core.resources.subtitle_wantsToJoin +import com.flipcash.core.resources.title_unnamedUser import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,13 +14,12 @@ import androidx.compose.runtime.Composable 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 org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ChatListRow @@ -120,14 +123,14 @@ private fun joinRequestText(waiting: List): String? { return when { waiting.isEmpty() -> null only != null -> stringResource( - R.string.subtitle_wantsToJoin, + Res.string.subtitle_wantsToJoin, chatterName( only.userProfile.displayName, only.userProfile.username, - stringResource(R.string.title_unnamedUser), + stringResource(Res.string.title_unnamedUser), ), ) - else -> pluralStringResource(R.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) + else -> pluralStringResource(Res.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt new file mode 100644 index 0000000000..638bb10a30 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/HostedRequests.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember + +/** + * The hosted groups with someone waiting, each with its waiting list, the group whose newest + * request is the most recent first. + */ +internal fun hostedRequests( + hosted: Collection, + lobbies: Map>, +): List>> = hosted + .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } + .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 455720743c..6ac7d39f63 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -197,6 +197,7 @@ include( ":apps:flipcash:features:search", ":apps:flipcash:features:user-profile", ":apps:flipcash:features:userflags", + ":apps:flipcash:features:chat-list", ":apps:flipcash:features:tipping", // Internal libs From 0ebab33bad72e34510842d74aa8f90449b33501c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 10:28:35 -0500 Subject: [PATCH 2/3] refactor(chat-list): move the chat list to commonMain behind Hilt subclasses ChatsViewModel and ArchivedChatsViewModel become open classes with an @Inject constructor; HiltChatsViewModel and HiltArchivedChatsViewModel in androidMain carry @HiltViewModel, as balance does for the wallet. The screens split into ChatsScreenContent / ArchivedChatsContent in commonMain and thin Android entries that own hiltViewModel, the navigator, the toast host and the title bar (TitleBar and the toast host are still androidMain in ui/components, so the bar is a slot). chatterName moves from an androidMain file in common-ui to commonMain; it is a pure string function. Tests that need no Android move to commonTest with kotlin.test and run on jvm too. --- .../app/tipping/ArchivedChatsScreen.kt | 132 +--- .../com/flipcash/app/tipping/ChatsScreen.kt | 586 ++---------------- .../internal/HiltArchivedChatsViewModel.kt | 37 ++ .../tipping/internal/HiltChatsViewModel.kt | 40 ++ .../app/tipping/ArchivedChatsContent.kt | 128 ++++ .../app/tipping/ChatsScreenContent.kt | 560 +++++++++++++++++ .../internal/ArchivedChatsViewModel.kt | 5 +- .../app/tipping/internal/ChatsViewModel.kt | 11 +- .../app/tipping/internal/ChipReveal.kt | 0 .../tipping/internal/ConversationMapping.kt | 0 .../tipping/internal/components/TipChatRow.kt | 0 .../internal/ChatsViewModelArchiveTest.kt | 25 +- .../internal/ChatsViewModelRatesTest.kt | 25 +- .../internal/ChatsViewModelStateTest.kt | 8 +- .../app/tipping/internal/ChipSettleTest.kt | 4 +- .../tipping/internal/JoinRequestRowTest.kt | 6 +- .../app/tipping/internal/TestDispatchers.kt | 14 + .../common/ui/profile/GroupChattingGrid.kt | 10 - .../shared/common/ui/profile/ChatterName.kt | 11 + 19 files changed, 882 insertions(+), 720 deletions(-) create mode 100644 apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt create mode 100644 apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt create mode 100644 apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt create mode 100644 apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt rename apps/flipcash/features/chat-list/src/{androidMain => commonMain}/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt (96%) rename apps/flipcash/features/chat-list/src/{androidMain => commonMain}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt (97%) rename apps/flipcash/features/chat-list/src/{androidMain => commonMain}/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt (100%) rename apps/flipcash/features/chat-list/src/{androidMain => commonMain}/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt (100%) rename apps/flipcash/features/chat-list/src/{androidMain => commonMain}/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt (100%) rename apps/flipcash/features/chat-list/src/{androidHostTest => commonTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt (88%) rename apps/flipcash/features/chat-list/src/{androidHostTest => commonTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt (90%) rename apps/flipcash/features/chat-list/src/{androidHostTest => commonTest}/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt (96%) rename apps/flipcash/features/chat-list/src/{androidHostTest => commonTest}/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt (97%) rename apps/flipcash/features/chat-list/src/{androidHostTest => commonTest}/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt (95%) create mode 100644 apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt create mode 100644 apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt index 2f23cad67d..d593115685 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -1,47 +1,20 @@ +// kmp-audit: hiltViewModel is Android-only; the screen content is ArchivedChatsContent in commonMain. package com.flipcash.app.tipping -import com.flipcash.core.resources.Res -import com.flipcash.core.resources.content_description_unarchiveChat -import com.flipcash.core.resources.title_archived -import com.flipcash.core.resources.title_noArchivedChats -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Unarchive -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import org.jetbrains.compose.resources.stringResource -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.data.isLoaded -import com.flipcash.app.tipping.internal.ArchivedChatsViewModel -import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.app.tipping.internal.HiltArchivedChatsViewModel +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.title_archived import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.SwipeAction -import com.getcode.ui.components.SwipeActionRow -import com.getcode.ui.components.SwipeRevealGroup -import com.getcode.ui.components.rememberSwipeRevealGroup -import com.getcode.ui.theme.CodeScaffold +import org.jetbrains.compose.resources.stringResource /** * The archived chats: the same row as the Chats list, sorted by last activity. A swipe on a row @@ -49,112 +22,21 @@ import com.getcode.ui.theme.CodeScaffold */ @Composable fun ArchivedChatsScreen() { - val viewModel = hiltViewModel() + val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current ArchivedChatsContent( chats = state.chats.dataOrNull.orEmpty(), isLoaded = state.chats.isLoaded(), - onBack = { navigator.pop() }, onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, onUnarchive = viewModel::unarchive, - ) -} - -@Composable -private fun ArchivedChatsContent( - chats: List, - isLoaded: Boolean, - onBack: () -> Unit, - onOpen: (ChatId) -> Unit, - onUnarchive: (ChatId) -> Unit, -) { - // One row open at a time, as on the Chats list. - val revealGroup = rememberSwipeRevealGroup() - - CodeScaffold( topBar = { AppBarWithTitle( title = stringResource(Res.string.title_archived), titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = onBack, + onBackIconClicked = { navigator.pop() }, ) }, - ) { padding -> - // One list for the rows and the empty state alike, so unarchiving the last chat fades its - // row out and the message in, rather than swapping the whole list for the message at once. - LazyColumn( - modifier = Modifier.fillMaxSize().testTag("archived_list"), - contentPadding = PaddingValues(top = padding.calculateTopPadding()), - ) { - if (isLoaded && chats.isEmpty()) { - item(key = "empty") { - Box( - modifier = Modifier - .animateItem() - .fillParentMaxSize() - .padding(bottom = padding.calculateBottomPadding()), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(Res.string.title_noArchivedChats), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } - } - } - // Keyed by chat so a row's swipe state stays with its chat as activity reorders. - // animateItem fades an unarchived row out where it stands and slides the rest up. - itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> - UnarchiveSwipeRow( - modifier = Modifier.animateItem(), - onUnarchive = { onUnarchive(chat.chatId) }, - stateKey = chat.chatId, - revealGroup = revealGroup, - ) { - // The row draws its divider below itself, so none sits above the first row. - TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { - onOpen(chat.chatId) - } - } - } - } - } -} - -@Composable -private fun UnarchiveSwipeRow( - onUnarchive: () -> Unit, - stateKey: Any, - revealGroup: SwipeRevealGroup, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val label = stringResource(Res.string.content_description_unarchiveChat) - SwipeActionRow( - actions = listOf( - SwipeAction( - background = CodeTheme.colors.surfaceVariant, - onTriggered = onUnarchive, - ) { - Icon( - imageVector = Icons.Outlined.Unarchive, - contentDescription = label, - tint = CodeTheme.colors.textMain, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), - ) - }, - ), - // A swipe is out of reach with a screen reader, so the row offers the action there. - modifier = modifier.semantics { - customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) - }, - stateKey = stateKey, - revealGroup = revealGroup, - content = content, ) } diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 026e64792b..41095c5c2e 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,181 +1,69 @@ +// kmp-audit: hiltViewModel is Android-only; the screen content is ChatsScreenContent in commonMain. package com.flipcash.app.tipping -import com.flipcash.core.resources.Res -import com.flipcash.core.resources.action_undo -import com.flipcash.core.resources.content_description_archiveChat -import com.flipcash.core.resources.content_description_archivedUnread -import com.flipcash.core.resources.content_description_changeMute -import com.flipcash.core.resources.content_description_muteChat -import com.flipcash.core.resources.description_noChatsYet -import com.flipcash.core.resources.ic_bubble_outline -import com.flipcash.core.resources.message_chatArchived -import com.flipcash.core.resources.title_allChatsArchived -import com.flipcash.core.resources.title_archived -import com.flipcash.core.resources.title_filterAll -import com.flipcash.core.resources.title_filterGroups -import com.flipcash.core.resources.title_filterUnread -import com.flipcash.core.resources.title_noChatsYet -import com.flipcash.core.resources.title_noGroupChats -import com.flipcash.core.resources.title_noUnreadChats -import com.flipcash.core.resources.title_tabChat -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.SnackbarResult -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.outlined.Archive -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import com.flipcash.app.tipping.internal.ChipRevealConnection -import com.flipcash.app.tipping.internal.ParkFiller -import com.flipcash.app.tipping.internal.blockTouchesWhile -import com.flipcash.app.tipping.internal.rememberParkFiller -import com.flipcash.app.tipping.internal.chipReveal -import com.flipcash.app.tipping.internal.chipVisibleFraction -import com.flipcash.shared.chat.ChatListFilter -import com.getcode.ui.components.FilterChip -import kotlinx.coroutines.launch -import androidx.compose.foundation.layout.Arrangement -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.requiredSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.NotificationsOff -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material.icons.outlined.Archive import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier -import com.flipcash.app.core.data.isLoaded -import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.app.tipping.internal.ChatsViewModel -import com.flipcash.app.tipping.internal.components.TipChatRow -import com.flipcash.shared.chat.ui.ConversationReference -import com.flipcash.shared.chat.ui.rememberIsMuted +import com.flipcash.app.tipping.internal.HiltChatsViewModel +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_undo +import com.flipcash.core.resources.message_chatArchived +import com.flipcash.core.resources.title_tabChat import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.TopScrollEdge -import com.getcode.ui.components.SwipeAction -import com.getcode.ui.components.SwipeActionRow -import com.getcode.ui.components.SwipeRevealGroup -import com.getcode.ui.components.rememberSwipeRevealGroup -import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.CodeScaffold -import com.getcode.ui.theme.ScaffoldBarPlacement -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState +import com.getcode.ui.components.toast.LocalFloatingToastHost +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource /** * The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss * affordance (the root nav bar is the chrome). The tip card lives on the "You" tab. * - * The All / Unread / Groups chips are the list's first item. The list starts on item 1, so they sit - * just out of view (under the bar, at zero alpha) until a pull from the top brings them on; see - * [ChipRevealConnection] for how a pull settles. + * Wires [ChatsScreenContent] to the Android view model, the navigator and the root toast host. */ @Composable fun ChatsScreen() { - val viewModel = hiltViewModel() + val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current - - val chats = state.chats - // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips - // shown or not) survives opening a chat and coming back. - val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (state.showsChips) 0 else 1) - val flingBehavior = ScrollableDefaults.flingBehavior() - val chipRevealConnection = remember(listState, flingBehavior, viewModel) { - ChipRevealConnection( - listState = listState, - flingBehavior = flingBehavior, - isShown = { viewModel.stateFlow.value.showsChips }, - // Nothing to filter while loading or with no chats at all. - canReveal = { viewModel.stateFlow.value.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, - onRevealed = viewModel::revealChips, - ) - } - val visible = state.visibleChats - // Everything between the chips and the filler, in list order. - val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { - listOf("archived") + when { - !chats.isLoaded() -> listOf("loading") - state.hasNoChatsAtAll -> listOf("empty") - visible.isEmpty() -> listOf("empty_filter") - else -> visible.map { it.chatId } - } - } - val parkFiller = rememberParkFiller() - SideEffect { parkFiller.retain(parkKeys) } - // Restored on the chips (after process death, say) with the ViewModel starting over: they are - // on screen, so they count as revealed rather than being parked by the next scroll. - LaunchedEffect(listState) { - if (listState.firstVisibleItemIndex == 0 && !viewModel.stateFlow.value.showsChips) { - viewModel.revealChips() - } - } - // One row open at a time: swiping another row closes the one left revealed. - val revealGroup = rememberSwipeRevealGroup() // The root toast host, so the undo rises out of the tab bar on its glass. val toasts = LocalFloatingToastHost.current val scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current val archivedMessage = stringResource(Res.string.message_chatArchived) val undoLabel = stringResource(Res.string.action_undo) - val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() - - val hazeState = rememberHazeState() - CodeScaffold( - // The list runs the full height and passes under the title bar, where it blurs into the bar - // (TopScrollEdge) as iOS's soft scroll edge does, rather than being cut off at the bar. - barPlacement = ScaffoldBarPlacement.Overlay, + ChatsScreenContent( + state = state, + onSelectFilter = viewModel::selectFilter, + onRevealChips = viewModel::revealChips, + onOpenArchived = { navigator.push(AppRoute.Messaging.ArchivedChats) }, + onOpenChat = { chat -> + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId))) + }, + onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) }, + onArchive = { chat -> + viewModel.archive(chat.chatId) + // The row leaves the list at once, so offer to put it back. + if (toasts != null) { + scope.launch { + val result = toasts.show( + message = archivedMessage, + icon = Icons.Outlined.Archive, + actionLabel = undoLabel, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) + } + } + }, topBar = { AppBarWithTitle( title = stringResource(Res.string.title_tabChat), @@ -190,407 +78,5 @@ fun ChatsScreen() { }, ) }, - ) { barPadding -> - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .hazeSource(hazeState) - // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this - // addresses the scrollable list itself. - .testTag("chat_list") - // Holds the chips out of view until a pull from the top brings them on. - .chipReveal(chipRevealConnection) - // End edge only — the start edge is the TopScrollEdge blur under the bar. - .verticalScrollStateGradient(scrollState = listState, showAtStart = false), - state = listState, - flingBehavior = flingBehavior, - contentPadding = PaddingValues( - // The bar's height as content padding rather than as a layout inset: the viewport - // runs the full height and rows scroll under the bar, but at rest the first row - // still sits clear of it. - top = barPadding.calculateTopPadding(), - // Clears the hoisted tab bar: keeps the last row reachable. Both paddings are - // measured out of `fillParentMaxSize`, so the empty states stay centered in the - // space the two bars leave visible. - bottom = tabBarPadding, - ), - ) { - item(key = "chips", contentType = "chips") { - ChatFilterRow( - modifier = Modifier - .blockTouchesWhile { !state.showsChips && listState.chipVisibleFraction() == 0f } - .graphicsLayer { - // Fades in with the pull. Read here, in the draw phase, so following - // the finger never recomposes the row. - alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() - }, - selected = state.filter, - unreadCount = state.projection.unreadChipCount, - groupsCount = state.projection.groupsChipCount, - onSelect = { filter -> - if (!state.showsChips && listState.chipVisibleFraction() < 1f) { - // Hidden under the bar (a stray tap, or a screen reader reaching it): - // bring the row on rather than switching filters out of sight. - viewModel.revealChips() - scope.launch { listState.animateScrollToItem(0) } - } else if (filter != state.filter) { - haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) - viewModel.selectFilter(filter) - } - }, - ) - } - - // Always emitted, so item 1 exists from the first composition (including while - // loading) and the parked position has a stable anchor; empty when there is no row. - // Its key never leaves the list, so animateItem only moves it; the row itself fades in - // and out inside the slot, while the rows below slide to make room or close the gap. - item(key = "archived", contentType = "archived") { - Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { - AnimatedVisibility( - visible = state.showsArchivedRow, - enter = fadeIn(), - exit = fadeOut(), - ) { - ArchivedRow( - count = state.projection.archivedRowCount, - onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, - ) - } - } - } - - when { - // A full-height spacer: with only the chips and an empty Archived slot the content - // is shorter than the viewport, the list fills backward to index 0, and the chips - // would show once rows arrive. - !chats.isLoaded() -> item(key = "loading") { - Spacer(parkFiller.tracked("loading").fillParentMaxSize()) - } - // The empty states fade like the rows do, so a filter or archive that empties the - // list doesn't drop the message on top of rows still fading out. - state.hasNoChatsAtAll -> item(key = "empty") { - NoChatsYet( - Modifier.animateItem().then(parkFiller.tracked("empty")).fillParentMaxSize() - ) - } - visible.isEmpty() -> item(key = "empty_filter") { - EmptyFilterState( - modifier = Modifier.animateItem() - .then(parkFiller.tracked("empty_filter")) - .fillParentMaxSize(), - message = when (state.filter) { - ChatListFilter.Unread -> Res.string.title_noUnreadChats - ChatListFilter.Groups -> Res.string.title_noGroupChats - ChatListFilter.All -> Res.string.title_allChatsArchived - }, - ) - } - else -> tipChatItems( - chats = visible, - onClick = { chat -> - navigator.push( - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) - ) - }, - // The same sheet the chat and group profiles open, so the list offers exactly - // the durations they do, and unmuting is its "Never" row rather than a toggle. - onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) }, - onArchive = { chat -> - viewModel.archive(chat.chatId) - // The row leaves the list at once, so offer to put it back. - if (toasts != null) { - scope.launch { - val result = toasts.show( - message = archivedMessage, - icon = Icons.Outlined.Archive, - actionLabel = undoLabel, - ) - if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) - } - } - }, - revealGroup = revealGroup, - parkFiller = parkFiller, - ) - } - - // Makes up a list shorter than the viewport, so it can still park; see [ParkFiller]. - item(key = "park_filler", contentType = "park_filler") { - Spacer(with(parkFiller) { filler(parkKeys) }) - } - } - TopScrollEdge( - hazeState = hazeState, - listState = listState, - height = barPadding.calculateTopPadding(), - ) - } - } -} - -/** - * The "Chats" tab empty state (node 9340:2746) — bubble mark, title and prompt, centered in the - * space the caller gives it (the list viewport). - */ -@Composable -private fun NoChatsYet(modifier: Modifier = Modifier) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - modifier = Modifier.size(CodeTheme.dimens.grid.x16), - painter = painterResource(Res.drawable.ic_bubble_outline), - contentDescription = null, - colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), - ) - - Text( - text = stringResource(Res.string.title_noChatsYet), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, - ) - - Text( - modifier = Modifier.fillMaxWidth(0.6f), - text = stringResource(Res.string.description_noChatsYet), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } - } -} - -private fun LazyListScope.tipChatItems( - chats: List, - onClick: (ConversationReference) -> Unit, - onMute: (ConversationReference) -> Unit, - onArchive: (ConversationReference) -> Unit, - revealGroup: SwipeRevealGroup, - parkFiller: ParkFiller, -) { - // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. - // animateItem fades a row out where it stands when it is archived (or filtered away), even - // swiped open, slides its neighbours into the gap, and fades it back in on Undo. - itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> - ChatSwipeRow( - modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), - isMuted = rememberIsMuted(chat.viewerState), - onMute = { onMute(chat) }, - onArchive = { onArchive(chat) }, - stateKey = chat.chatId, - revealGroup = revealGroup, - ) { - TipChatRow( - chat = chat, - showDivider = index < chats.lastIndex, - ) { - onClick(chat) - } - } - } -} - -/** - * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action - * outermost and fires it on a full swipe, so a full swipe still opens the mute sheet as before, and - * archive is a tap on the revealed action. - * - * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible - * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never - * mutes directly, because a mute always needs a duration. It resets rather than settling open: a - * row left swiped under the sheet would still be sitting open once the sheet closed. - * - * Archive removes the row, which disposes its swipe state, so Undo brings the chat back as a fresh, - * closed row. - */ -@Composable -private fun ChatSwipeRow( - isMuted: Boolean, - onMute: () -> Unit, - onArchive: () -> Unit, - stateKey: Any, - revealGroup: SwipeRevealGroup, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val muteLabel = stringResource( - if (isMuted) Res.string.content_description_changeMute - else Res.string.content_description_muteChat - ) - val archiveLabel = stringResource(Res.string.content_description_archiveChat) - SwipeActionRow( - actions = listOf( - SwipeAction( - background = CodeTheme.colors.surfaceVariant, - onTriggered = onArchive, - ) { - Icon( - imageVector = Icons.Outlined.Archive, - contentDescription = archiveLabel, - tint = CodeTheme.colors.textMain, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), - ) - }, - SwipeAction( - // The profile's unverified badge treatment rather than the delete red: nothing is - // lost by it. - background = CodeTheme.colors.warning.copy(alpha = 0.15f), - onTriggered = onMute, - resetOnDismiss = true, - ) { - Icon( - imageVector = if (isMuted) { - Icons.Outlined.Notifications - } else { - Icons.Outlined.NotificationsOff - }, - contentDescription = muteLabel, - tint = CodeTheme.colors.warning, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), - ) - }, - ), - // A swipe is out of reach with a screen reader, so the row offers the same actions there. - modifier = modifier.semantics { - customActions = listOf( - CustomAccessibilityAction(muteLabel) { onMute(); true }, - CustomAccessibilityAction(archiveLabel) { onArchive(); true }, - ) - }, - stateKey = stateKey, - revealGroup = revealGroup, - content = content, ) } - -/** - * The filter chips. All carries no number: the tab badge already shows it. Unread and Groups show - * theirs only when non-zero. - */ -@Composable -private fun ChatFilterRow( - selected: ChatListFilter, - unreadCount: Int, - groupsCount: Int, - onSelect: (ChatListFilter) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .selectableGroup() - .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - FilterChip( - label = stringResource(Res.string.title_filterAll), - selected = selected == ChatListFilter.All, - onClick = { onSelect(ChatListFilter.All) }, - ) - FilterChip( - label = stringResource(Res.string.title_filterUnread), - selected = selected == ChatListFilter.Unread, - onClick = { onSelect(ChatListFilter.Unread) }, - count = unreadCount, - ) - FilterChip( - label = stringResource(Res.string.title_filterGroups), - selected = selected == ChatListFilter.Groups, - onClick = { onSelect(ChatListFilter.Groups) }, - count = groupsCount, - ) - } -} - -/** - * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads - * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. - */ -@Composable -private fun ArchivedRow(count: Int, onClick: () -> Unit) { - val description = if (count > 0) { - stringResource(Res.string.content_description_archivedUnread, count) - } else { - stringResource(Res.string.title_archived) - } - val color = CodeTheme.colors.textSecondary - Row( - modifier = Modifier - .fillMaxWidth() - .background(CodeTheme.colors.background) - .clickable(onClick = onClick) - .clearAndSetSemantics { - contentDescription = description - role = Role.Button - } - .testTag("archived_row") - .padding(vertical = CodeTheme.dimens.grid.x3) - .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - // Centred in the avatar column, so the label lines up with the chat names below. - Box( - modifier = Modifier.width(CodeTheme.dimens.staticGrid.x8), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Archive, - contentDescription = null, - tint = color, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), - ) - } - Text( - modifier = Modifier.weight(1f), - text = stringResource(Res.string.title_archived), - style = CodeTheme.typography.textMedium, - color = color, - ) - if (count > 0) { - Text( - text = count.toString(), - style = CodeTheme.typography.textSmall, - color = color, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = color, - ) - } -} - -/** A filter with nothing under it, centred in the space it is given (the list viewport). */ -@Composable -private fun EmptyFilterState(message: StringResource, modifier: Modifier = Modifier) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Text( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(message), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } -} - -@Composable -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -private fun PreviewNoChatsYet() { - NoChatsYet(Modifier.fillMaxSize()) -} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt new file mode 100644 index 0000000000..c118e2e065 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.PaymentRequests +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** Hilt entry point for the shared [ArchivedChatsViewModel]. */ +@HiltViewModel +internal class HiltArchivedChatsViewModel @Inject constructor( + exchange: Exchange, + chatCoordinator: ChatCoordinator, + archiveStore: ChatArchiveStore, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + resources: ResourceHelper, + dispatchers: DispatcherProvider, + paymentRequests: PaymentRequests, + hostLobbies: HostLobbies, +) : ArchivedChatsViewModel( + exchange = exchange, + chatCoordinator = chatCoordinator, + archiveStore = archiveStore, + userManager = userManager, + tokenCoordinator = tokenCoordinator, + resources = resources, + dispatchers = dispatchers, + paymentRequests = paymentRequests, + hostLobbies = hostLobbies, +) diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt new file mode 100644 index 0000000000..c053196da3 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.PaymentRequests +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** Hilt entry point for the shared [ChatsViewModel]. */ +@HiltViewModel +internal class HiltChatsViewModel @Inject constructor( + exchange: Exchange, + chatCoordinator: ChatCoordinator, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + resources: ResourceHelper, + dispatchers: DispatcherProvider, + archiveStore: ChatArchiveStore, + lobbyRequests: LobbyRequests, + hostLobbies: HostLobbies, + paymentRequests: PaymentRequests, +) : ChatsViewModel( + exchange = exchange, + chatCoordinator = chatCoordinator, + userManager = userManager, + tokenCoordinator = tokenCoordinator, + resources = resources, + dispatchers = dispatchers, + archiveStore = archiveStore, + lobbyRequests = lobbyRequests, + hostLobbies = hostLobbies, + paymentRequests = paymentRequests, +) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt new file mode 100644 index 0000000000..ada9ae2955 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.tipping + +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.content_description_unarchiveChat +import com.flipcash.core.resources.title_noArchivedChats +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Unarchive +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import org.jetbrains.compose.resources.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.tipping.internal.ArchivedChatsViewModel +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup +import com.getcode.ui.theme.CodeScaffold + +@Composable +internal fun ArchivedChatsContent( + chats: List, + isLoaded: Boolean, + onOpen: (ChatId) -> Unit, + onUnarchive: (ChatId) -> Unit, + topBar: @Composable () -> Unit, +) { + // One row open at a time, as on the Chats list. + val revealGroup = rememberSwipeRevealGroup() + + CodeScaffold( + topBar = topBar, + ) { padding -> + // One list for the rows and the empty state alike, so unarchiving the last chat fades its + // row out and the message in, rather than swapping the whole list for the message at once. + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("archived_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + if (isLoaded && chats.isEmpty()) { + item(key = "empty") { + Box( + modifier = Modifier + .animateItem() + .fillParentMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(Res.string.title_noArchivedChats), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + // Keyed by chat so a row's swipe state stays with its chat as activity reorders. + // animateItem fades an unarchived row out where it stands and slides the rest up. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + UnarchiveSwipeRow( + modifier = Modifier.animateItem(), + onUnarchive = { onUnarchive(chat.chatId) }, + stateKey = chat.chatId, + revealGroup = revealGroup, + ) { + // The row draws its divider below itself, so none sits above the first row. + TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { + onOpen(chat.chatId) + } + } + } + } + } +} + +@Composable +private fun UnarchiveSwipeRow( + onUnarchive: () -> Unit, + stateKey: Any, + revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val label = stringResource(Res.string.content_description_unarchiveChat) + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onUnarchive, + ) { + Icon( + imageVector = Icons.Outlined.Unarchive, + contentDescription = label, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, + ), + // A swipe is out of reach with a screen reader, so the row offers the action there. + modifier = modifier.semantics { + customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) + }, + stateKey = stateKey, + revealGroup = revealGroup, + content = content, + ) +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt new file mode 100644 index 0000000000..4ad16825b2 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -0,0 +1,560 @@ +package com.flipcash.app.tipping + +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_undo +import com.flipcash.core.resources.content_description_archiveChat +import com.flipcash.core.resources.content_description_archivedUnread +import com.flipcash.core.resources.content_description_changeMute +import com.flipcash.core.resources.content_description_muteChat +import com.flipcash.core.resources.description_noChatsYet +import com.flipcash.core.resources.ic_bubble_outline +import com.flipcash.core.resources.message_chatArchived +import com.flipcash.core.resources.title_allChatsArchived +import com.flipcash.core.resources.title_archived +import com.flipcash.core.resources.title_filterAll +import com.flipcash.core.resources.title_filterGroups +import com.flipcash.core.resources.title_filterUnread +import com.flipcash.core.resources.title_noChatsYet +import com.flipcash.core.resources.title_noGroupChats +import com.flipcash.core.resources.title_noUnreadChats +import com.flipcash.core.resources.title_tabChat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import com.flipcash.app.tipping.internal.ChipRevealConnection +import com.flipcash.app.tipping.internal.ParkFiller +import com.flipcash.app.tipping.internal.blockTouchesWhile +import com.flipcash.app.tipping.internal.rememberParkFiller +import com.flipcash.app.tipping.internal.chipReveal +import com.flipcash.app.tipping.internal.chipVisibleFraction +import com.flipcash.shared.chat.ChatListFilter +import com.getcode.ui.components.FilterChip +import kotlinx.coroutines.launch +import androidx.compose.foundation.layout.Arrangement +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.requiredSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.app.tipping.internal.ChatsViewModel +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.rememberIsMuted +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.TopScrollEdge +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.theme.ScaffoldBarPlacement +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +/** + * The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss + * affordance (the root nav bar is the chrome). The tip card lives on the "You" tab. + * + * The All / Unread / Groups chips are the list's first item. The list starts on item 1, so they sit + * just out of view (under the bar, at zero alpha) until a pull from the top brings them on; see + * [ChipRevealConnection] for how a pull settles. + */ +@Composable +fun ChatsScreenContent( + state: ChatsViewModel.State, + onSelectFilter: (ChatListFilter) -> Unit, + onRevealChips: () -> Unit, + onOpenArchived: () -> Unit, + onOpenChat: (ConversationReference) -> Unit, + onMute: (ConversationReference) -> Unit, + onArchive: (ConversationReference) -> Unit, + topBar: @Composable () -> Unit, +) { + val chats = state.chats + // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips + // shown or not) survives opening a chat and coming back. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (state.showsChips) 0 else 1) + val flingBehavior = ScrollableDefaults.flingBehavior() + val currentState by rememberUpdatedState(state) + val chipRevealConnection = remember(listState, flingBehavior) { + ChipRevealConnection( + listState = listState, + flingBehavior = flingBehavior, + isShown = { currentState.showsChips }, + // Nothing to filter while loading or with no chats at all. + canReveal = { currentState.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, + onRevealed = onRevealChips, + ) + } + val visible = state.visibleChats + // Everything between the chips and the filler, in list order. + val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { + listOf("archived") + when { + !chats.isLoaded() -> listOf("loading") + state.hasNoChatsAtAll -> listOf("empty") + visible.isEmpty() -> listOf("empty_filter") + else -> visible.map { it.chatId } + } + } + val parkFiller = rememberParkFiller() + SideEffect { parkFiller.retain(parkKeys) } + // Restored on the chips (after process death, say) with the ViewModel starting over: they are + // on screen, so they count as revealed rather than being parked by the next scroll. + LaunchedEffect(listState) { + if (listState.firstVisibleItemIndex == 0 && !currentState.showsChips) { + onRevealChips() + } + } + // One row open at a time: swiping another row closes the one left revealed. + val revealGroup = rememberSwipeRevealGroup() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() + + val hazeState = rememberHazeState() + + CodeScaffold( + // The list runs the full height and passes under the title bar, where it blurs into the bar + // (TopScrollEdge) as iOS's soft scroll edge does, rather than being cut off at the bar. + barPlacement = ScaffoldBarPlacement.Overlay, + topBar = topBar, + ) { barPadding -> + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this + // addresses the scrollable list itself. + .testTag("chat_list") + // Holds the chips out of view until a pull from the top brings them on. + .chipReveal(chipRevealConnection) + // End edge only — the start edge is the TopScrollEdge blur under the bar. + .verticalScrollStateGradient(scrollState = listState, showAtStart = false), + state = listState, + flingBehavior = flingBehavior, + contentPadding = PaddingValues( + // The bar's height as content padding rather than as a layout inset: the viewport + // runs the full height and rows scroll under the bar, but at rest the first row + // still sits clear of it. + top = barPadding.calculateTopPadding(), + // Clears the hoisted tab bar: keeps the last row reachable. Both paddings are + // measured out of `fillParentMaxSize`, so the empty states stay centered in the + // space the two bars leave visible. + bottom = tabBarPadding, + ), + ) { + item(key = "chips", contentType = "chips") { + ChatFilterRow( + modifier = Modifier + .blockTouchesWhile { !state.showsChips && listState.chipVisibleFraction() == 0f } + .graphicsLayer { + // Fades in with the pull. Read here, in the draw phase, so following + // the finger never recomposes the row. + alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() + }, + selected = state.filter, + unreadCount = state.projection.unreadChipCount, + groupsCount = state.projection.groupsChipCount, + onSelect = { filter -> + if (!state.showsChips && listState.chipVisibleFraction() < 1f) { + // Hidden under the bar (a stray tap, or a screen reader reaching it): + // bring the row on rather than switching filters out of sight. + onRevealChips() + scope.launch { listState.animateScrollToItem(0) } + } else if (filter != state.filter) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onSelectFilter(filter) + } + }, + ) + } + + // Always emitted, so item 1 exists from the first composition (including while + // loading) and the parked position has a stable anchor; empty when there is no row. + // Its key never leaves the list, so animateItem only moves it; the row itself fades in + // and out inside the slot, while the rows below slide to make room or close the gap. + item(key = "archived", contentType = "archived") { + Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { + AnimatedVisibility( + visible = state.showsArchivedRow, + enter = fadeIn(), + exit = fadeOut(), + ) { + ArchivedRow( + count = state.projection.archivedRowCount, + onClick = onOpenArchived, + ) + } + } + } + + when { + // A full-height spacer: with only the chips and an empty Archived slot the content + // is shorter than the viewport, the list fills backward to index 0, and the chips + // would show once rows arrive. + !chats.isLoaded() -> item(key = "loading") { + Spacer(parkFiller.tracked("loading").fillParentMaxSize()) + } + // The empty states fade like the rows do, so a filter or archive that empties the + // list doesn't drop the message on top of rows still fading out. + state.hasNoChatsAtAll -> item(key = "empty") { + NoChatsYet( + Modifier.animateItem().then(parkFiller.tracked("empty")).fillParentMaxSize() + ) + } + visible.isEmpty() -> item(key = "empty_filter") { + EmptyFilterState( + modifier = Modifier.animateItem() + .then(parkFiller.tracked("empty_filter")) + .fillParentMaxSize(), + message = when (state.filter) { + ChatListFilter.Unread -> Res.string.title_noUnreadChats + ChatListFilter.Groups -> Res.string.title_noGroupChats + ChatListFilter.All -> Res.string.title_allChatsArchived + }, + ) + } + else -> tipChatItems( + chats = visible, + onClick = onOpenChat, + // The same sheet the chat and group profiles open, so the list offers exactly + // the durations they do, and unmuting is its "Never" row rather than a toggle. + onMute = onMute, + onArchive = onArchive, + revealGroup = revealGroup, + parkFiller = parkFiller, + ) + } + + // Makes up a list shorter than the viewport, so it can still park; see [ParkFiller]. + item(key = "park_filler", contentType = "park_filler") { + Spacer(with(parkFiller) { filler(parkKeys) }) + } + } + TopScrollEdge( + hazeState = hazeState, + listState = listState, + height = barPadding.calculateTopPadding(), + ) + } + } +} + +/** + * The "Chats" tab empty state (node 9340:2746) — bubble mark, title and prompt, centered in the + * space the caller gives it (the list viewport). + */ +@Composable +private fun NoChatsYet(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.size(CodeTheme.dimens.grid.x16), + painter = painterResource(Res.drawable.ic_bubble_outline), + contentDescription = null, + colorFilter = ColorFilter.tint(CodeTheme.colors.textMain), + ) + + Text( + text = stringResource(Res.string.title_noChatsYet), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = stringResource(Res.string.description_noChatsYet), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + +private fun LazyListScope.tipChatItems( + chats: List, + onClick: (ConversationReference) -> Unit, + onMute: (ConversationReference) -> Unit, + onArchive: (ConversationReference) -> Unit, + revealGroup: SwipeRevealGroup, + parkFiller: ParkFiller, +) { + // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. + // animateItem fades a row out where it stands when it is archived (or filtered away), even + // swiped open, slides its neighbours into the gap, and fades it back in on Undo. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + ChatSwipeRow( + modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), + isMuted = rememberIsMuted(chat.viewerState), + onMute = { onMute(chat) }, + onArchive = { onArchive(chat) }, + stateKey = chat.chatId, + revealGroup = revealGroup, + ) { + TipChatRow( + chat = chat, + showDivider = index < chats.lastIndex, + ) { + onClick(chat) + } + } + } +} + +/** + * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action + * outermost and fires it on a full swipe, so a full swipe still opens the mute sheet as before, and + * archive is a tap on the revealed action. + * + * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible + * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never + * mutes directly, because a mute always needs a duration. It resets rather than settling open: a + * row left swiped under the sheet would still be sitting open once the sheet closed. + * + * Archive removes the row, which disposes its swipe state, so Undo brings the chat back as a fresh, + * closed row. + */ +@Composable +private fun ChatSwipeRow( + isMuted: Boolean, + onMute: () -> Unit, + onArchive: () -> Unit, + stateKey: Any, + revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val muteLabel = stringResource( + if (isMuted) Res.string.content_description_changeMute + else Res.string.content_description_muteChat + ) + val archiveLabel = stringResource(Res.string.content_description_archiveChat) + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onArchive, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = archiveLabel, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, + SwipeAction( + // The profile's unverified badge treatment rather than the delete red: nothing is + // lost by it. + background = CodeTheme.colors.warning.copy(alpha = 0.15f), + onTriggered = onMute, + resetOnDismiss = true, + ) { + Icon( + imageVector = if (isMuted) { + Icons.Outlined.Notifications + } else { + Icons.Outlined.NotificationsOff + }, + contentDescription = muteLabel, + tint = CodeTheme.colors.warning, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, + ), + // A swipe is out of reach with a screen reader, so the row offers the same actions there. + modifier = modifier.semantics { + customActions = listOf( + CustomAccessibilityAction(muteLabel) { onMute(); true }, + CustomAccessibilityAction(archiveLabel) { onArchive(); true }, + ) + }, + stateKey = stateKey, + revealGroup = revealGroup, + content = content, + ) +} + +/** + * The filter chips. All carries no number: the tab badge already shows it. Unread and Groups show + * theirs only when non-zero. + */ +@Composable +private fun ChatFilterRow( + selected: ChatListFilter, + unreadCount: Int, + groupsCount: Int, + onSelect: (ChatListFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .selectableGroup() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + FilterChip( + label = stringResource(Res.string.title_filterAll), + selected = selected == ChatListFilter.All, + onClick = { onSelect(ChatListFilter.All) }, + ) + FilterChip( + label = stringResource(Res.string.title_filterUnread), + selected = selected == ChatListFilter.Unread, + onClick = { onSelect(ChatListFilter.Unread) }, + count = unreadCount, + ) + FilterChip( + label = stringResource(Res.string.title_filterGroups), + selected = selected == ChatListFilter.Groups, + onClick = { onSelect(ChatListFilter.Groups) }, + count = groupsCount, + ) + } +} + +/** + * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads + * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. + */ +@Composable +private fun ArchivedRow(count: Int, onClick: () -> Unit) { + val description = if (count > 0) { + stringResource(Res.string.content_description_archivedUnread, count) + } else { + stringResource(Res.string.title_archived) + } + val color = CodeTheme.colors.textSecondary + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + } + .testTag("archived_row") + .padding(vertical = CodeTheme.dimens.grid.x3) + .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // Centred in the avatar column, so the label lines up with the chat names below. + Box( + modifier = Modifier.width(CodeTheme.dimens.staticGrid.x8), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = null, + tint = color, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } + Text( + modifier = Modifier.weight(1f), + text = stringResource(Res.string.title_archived), + style = CodeTheme.typography.textMedium, + color = color, + ) + if (count > 0) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = color, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = color, + ) + } +} + +/** A filter with nothing under it, centred in the space it is given (the list viewport). */ +@Composable +private fun EmptyFilterState(message: StringResource, modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(message), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +private fun PreviewNoChatsYet() { + NoChatsYet(Modifier.fillMaxSize()) +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt similarity index 96% rename from apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index 7db4036db4..a3bc0568b5 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -13,7 +13,6 @@ import com.flipcash.shared.chat.archivedChatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.PaymentRequests @@ -24,8 +23,8 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** Backs the Archived screen: the archived chats, newest first, and unarchive. */ -@HiltViewModel -internal class ArchivedChatsViewModel @Inject constructor( +/** Android injects it through `HiltArchivedChatsViewModel`. */ +internal open class ArchivedChatsViewModel @Inject constructor( private val exchange: Exchange, private val chatCoordinator: ChatCoordinator, private val archiveStore: ChatArchiveStore, diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt similarity index 97% rename from apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 8066975e00..6dba8a2010 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -2,7 +2,6 @@ package com.flipcash.app.tipping.internal import com.getcode.opencode.exchange.Exchange import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.data.Loadable import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator @@ -39,9 +38,11 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -/** Backs the "Chats" tab: the user's tip DMs and groups. */ -@HiltViewModel -internal class ChatsViewModel @Inject constructor( +/** + * Backs the "Chats" tab: the user's tip DMs and groups. Android injects it through + * `HiltChatsViewModel`; desktop builds it from its own graph. + */ +open class ChatsViewModel @Inject constructor( private val exchange: Exchange, private val chatCoordinator: ChatCoordinator, userManager: UserManager, @@ -198,7 +199,7 @@ internal class ChatsViewModel @Inject constructor( .launchIn(viewModelScope) } - internal companion object { + companion object { internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { is Event.ChatsUpdated -> { state -> diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt similarity index 100% rename from apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt similarity index 100% rename from apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt similarity index 100% rename from apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt rename to apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt similarity index 88% rename from apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index 02041a755c..53c4249fad 100644 --- a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -1,7 +1,5 @@ package com.flipcash.app.tipping.internal -import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -13,7 +11,7 @@ import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Rate -import com.getcode.util.resources.FakeResourceHelper +import com.getcode.util.resources.ResourceHelper import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,8 +21,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Instant @@ -37,8 +39,13 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class ChatsViewModelArchiveTest { - @get:Rule - var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + private val mainDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(mainDispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() private val chat = ChatSummary( metadata = ChatMetadata( @@ -55,7 +62,7 @@ class ChatsViewModelArchiveTest { @Test fun `archiving the last main chat never shows the no-chats prompt`() = - runTest(mainCoroutineRule.dispatcher) { + runTest(mainDispatcher) { val feeds = MutableStateFlow(ChatFeeds(main = listOf(chat), archived = emptyList())) val chatCoordinator = mockk(relaxed = true).also { every { it.feedWithArchived(*anyVararg()) } returns feeds @@ -74,7 +81,7 @@ class ChatsViewModelArchiveTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(), + resources = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt similarity index 90% rename from apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt index 2079e9a33d..e390a0b047 100644 --- a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -1,7 +1,5 @@ package com.flipcash.app.tipping.internal -import com.flipcash.app.core.MainCoroutineRule -import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage @@ -18,7 +16,7 @@ import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint -import com.getcode.util.resources.FakeResourceHelper +import com.getcode.util.resources.ResourceHelper import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,8 +26,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Instant @@ -41,8 +43,13 @@ import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class ChatsViewModelRatesTest { - @get:Rule - var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + private val mainDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(mainDispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() private val cad = Rate(fx = 1.4, currency = CurrencyCode.CAD) private val gbp = Rate(fx = 0.8, currency = CurrencyCode.GBP) @@ -74,7 +81,7 @@ class ChatsViewModelRatesTest { @Test fun `a payment preview redraws when the sent currency's rate lands after the display rate`() = - runTest(mainCoroutineRule.dispatcher) { + runTest(mainDispatcher) { val preferred = MutableStateFlow(cad) val rates = MutableStateFlow(mapOf(CurrencyCode.CAD to cad)) val exchange = mockk(relaxed = true).also { @@ -98,7 +105,7 @@ class ChatsViewModelRatesTest { chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, - resources = FakeResourceHelper(), + resources = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt similarity index 96% rename from apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt index 52a3198eb1..c5da4ba794 100644 --- a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt @@ -5,10 +5,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatListFilter import com.flipcash.shared.chat.ChatListProjection import com.flipcash.shared.chat.ui.ConversationReference -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.Test class ChatsViewModelStateTest { diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt similarity index 97% rename from apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt index 45a647863c..ac47e036a3 100644 --- a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt @@ -1,7 +1,7 @@ package com.flipcash.app.tipping.internal -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.Test class ChipSettleTest { diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt similarity index 95% rename from apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt rename to apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt index 2403867e06..029995176e 100644 --- a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/JoinRequestRowTest.kt @@ -5,9 +5,9 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.solana.keys.PublicKey -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.Test import kotlin.time.Instant /** What a hosted group's row draws while someone waits to join it, and where it sits. */ diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt new file mode 100644 index 0000000000..0056894eb8 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/TestDispatchers.kt @@ -0,0 +1,14 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.libs.coroutines.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler + +internal class TestDispatchers(scheduler: TestCoroutineScheduler) : DispatcherProvider { + private val dispatcher = StandardTestDispatcher(scheduler) + + override val IO: CoroutineDispatcher = dispatcher + override val Main: CoroutineDispatcher = dispatcher + override val Default: CoroutineDispatcher = dispatcher +} diff --git a/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt b/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt index 74b889aa96..c6e884e88b 100644 --- a/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt +++ b/apps/flipcash/shared/common-ui/src/androidMain/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt @@ -56,16 +56,6 @@ data class ChattingGridItem( val isHost: Boolean, ) -/** - * What a chatter is called under their portrait: their display name, else their handle, else - * [fallback]. Blank counts as missing, since the server sends an empty display name for a profile - * that has none. - */ -fun chatterName(displayName: String?, username: String?, fallback: String): String = - displayName?.takeIf { it.isNotBlank() } - ?: username?.takeIf { it.isNotBlank() }?.let { "@$it" } - ?: fallback - /** * Who has been talking in a public group: a "Chatting" heading over portraits four to a row, the * host badged. Draws nothing for an empty list, heading included. diff --git a/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt b/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt new file mode 100644 index 0000000000..4aa0a7a0ef --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/commonMain/kotlin/com/flipcash/shared/common/ui/profile/ChatterName.kt @@ -0,0 +1,11 @@ +package com.flipcash.shared.common.ui.profile + +/** + * What a chatter is called under their portrait: their display name, else their handle, else + * [fallback]. Blank counts as missing, since the server sends an empty display name for a profile + * that has none. + */ +fun chatterName(displayName: String?, username: String?, fallback: String): String = + displayName?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: fallback From 0439c0b0a8dc29b41a72fe43128c418861ec0aa2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 10:35:55 -0500 Subject: [PATCH 3/3] feat(desktop): put the chat list's ChatsViewModel and RealChatCoordinator in the desktop graph --- apps/desktop/build.gradle.kts | 6 ++ .../com/flipcash/desktop/DesktopGraph.kt | 3 + .../com/flipcash/desktop/DesktopModule.kt | 63 ++++++++++++++++++- .../com/flipcash/desktop/DesktopWiringTest.kt | 10 +++ .../app/startup/contract/StartupContract.kt | 3 +- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 295ce4aad7..3de4ba15df 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -12,6 +12,10 @@ kotlin { } metro { + // Dagger ignores Kotlin defaults, so shared classes like RealChatCoordinator list defaulted + // parameters that Android always injects. Metro's default treats those as optional and orders + // the graph without them, which hands the factory a null provider at startup. + optionalBindingBehavior.set(dev.zacsweers.metro.gradle.OptionalBindingBehavior.REQUIRE_OPTIONAL_BINDING) interop { includeJavax() includeDagger() @@ -45,6 +49,8 @@ dependencies { implementation(project(":apps:flipcash:shared:device-link")) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":apps:flipcash:features:chat-list")) + implementation(project(":libs:emojis")) implementation(libs.kotlinx.coroutines.swing) implementation(libs.dagger) implementation(libs.grpc.okhttp) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt index 20230ac7e8..883d485b7f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt @@ -29,10 +29,13 @@ import javax.inject.Singleton FlipcashDesktopChannelModule::class, DesktopModule::class, com.flipcash.app.blob.JvmBlobModule::class, + com.flipcash.shared.chat.inject.ChatModule::class, + com.flipcash.shared.chat.inject.RecentReactionsOwnerModule::class, ], ) interface DesktopGraph { val walletViewModel: WalletViewModel + val chatsViewModel: com.flipcash.app.tipping.internal.ChatsViewModel val selectTokenViewModel: SelectTokenViewModel val exchange: Exchange val resources: ResourceHelper diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt index 04f53c6ea9..8550fefe3f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt @@ -7,6 +7,15 @@ import com.flipcash.app.blob.FilePickedPhotos import com.flipcash.app.blob.JvmUploadImageCache import com.flipcash.app.blob.PickedPhotos import com.flipcash.app.blob.UploadImageCache +import com.flipcash.shared.chat.AppForeground +import com.flipcash.shared.chat.ChatNotifications +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.PaymentRequests +import com.flipcash.app.persistence.sources.IncomingMessageOpener +import com.flipcash.services.chat.GroupKeyEnvelopeStore +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.controllers.ChatController import com.flipcash.shared.chat.media.ChatMediaLocation import com.flipcash.shared.chat.media.DirectoryChatMediaLocation import com.flipcash.shared.chat.media.ImageLoaderSentImageCache @@ -26,6 +35,8 @@ import com.flipcash.app.userflags.UserFlagsFiles import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.DefaultChatCipher +import com.getcode.chatcipher.DefaultInviteCipher +import com.getcode.chatcipher.InviteCipher import com.getcode.opencode.providers.SessionListener import com.getcode.opencode.providers.TokenMetadataProvider import com.getcode.util.locale.LocaleHelper @@ -33,6 +44,9 @@ import com.getcode.util.resources.ComposeResourceHelper import com.getcode.util.resources.ResourceHelper import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.network.NetworkObserverStub +import com.getcode.libs.emojis.reactions.DataStoreRecentReactionsStore +import com.getcode.libs.emojis.reactions.RecentReactionsOwner +import com.getcode.libs.emojis.reactions.RecentReactionsStore import dagger.Module import dagger.Provides import dagger.multibindings.IntoSet @@ -70,11 +84,14 @@ object DesktopModule { override val IO: CoroutineDispatcher = Dispatchers.IO } - // ChatCryptoModule provides this from its companion object, which Metro's Dagger interop + // ChatCryptoModule provides these from its companion object, which Metro's Dagger interop // does not read from a precompiled module. @Provides fun chatCipher(): ChatCipher = DefaultChatCipher + @Provides + fun inviteCipher(): InviteCipher = DefaultInviteCipher + @Provides fun analytics(): FlipcashAnalytics = FlipcashAnalytics.None @@ -121,6 +138,50 @@ object DesktopModule { fun sentImageCache(): SentImageCache = ImageLoaderSentImageCache { SingletonImageLoader.get(PlatformContext.INSTANCE) } + // A desktop window that is running counts as foreground; there is no process lifecycle to observe. + @Provides + @Singleton + fun appForeground(): AppForeground = object : AppForeground { + override val isForeground: Boolean = true + override fun observe(observer: androidx.lifecycle.DefaultLifecycleObserver) = Unit + } + + // No system notifications on desktop yet, so there is nothing for chat to dismiss. + @Provides + @Singleton + fun chatNotifications(): ChatNotifications = ChatNotifications.None + + // HostInvites keeps its @Inject on a secondary constructor, which Metro's Dagger interop does not read. + @Provides + @Singleton + fun hostInvites( + chatController: ChatController, + groupKeys: GroupKeySource, + keys: ChatKeySource, + cipher: InviteCipher, + ): HostInvites = HostInvites(chatController, groupKeys, keys, cipher) + + // RealChatCoordinator and MessagingDelegate take these as `T? = null`. Dagger sees them as required; Metro, with + // REQUIRE_OPTIONAL_BINDING (see build.gradle.kts), wants a binding for the nullable type itself. + @Provides + fun optionalHostInvites(value: HostInvites): HostInvites? = value + + @Provides + fun optionalPaymentRequests(value: PaymentRequests): PaymentRequests? = value + + @Provides + fun optionalIncomingOpener(value: IncomingMessageOpener): IncomingMessageOpener? = value + + @Provides + fun optionalGroupKeyEnvelopes(value: GroupKeyEnvelopeStore): GroupKeyEnvelopeStore? = value + + // emojis' ReactionsModule is a Dagger module that module does not run Dagger's compiler for on the JVM, + // so Metro cannot read it; this is its provider. + @Provides + @Singleton + fun recentReactionsStore(files: PreferencesFiles, owner: RecentReactionsOwner): RecentReactionsStore = + DataStoreRecentReactionsStore(files, owner) + @Provides @Singleton fun resources(): ResourceHelper = ComposeResourceHelper() diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt index c4fa9c7714..737500b85e 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/DesktopWiringTest.kt @@ -94,6 +94,16 @@ class DesktopWiringTest { assertSame(graph.sentImageCache, graph.sentImageCache, "SentImageCache") } + @Test + fun `chat list resolves and its coordinator is a session listener`() { + // ChatsViewModel is the sidebar list's state holder; RealChatCoordinator in the listener set is + // what starts the feed sync on login, so a missing @IntoSet leaves the list empty without an error. + val viewModel = graph.chatsViewModel + assertTrue(viewModel is com.flipcash.app.tipping.internal.ChatsViewModel) + val coordinator = graph.sessionListeners.filterIsInstance() + assertEquals(1, coordinator.size, "RealChatCoordinator in the SessionListener set") + } + /** * The repository subscribes to `Events.OnLoggedIn` on the shared bus. Closing that key with * REQUIRE_FLOW_IS_NOT_COLLECTING fails only while someone collects it; the subscription is diff --git a/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt b/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt index 978d9a48c5..f1e8b30d42 100644 --- a/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt +++ b/apps/flipcash/startup-contract/src/commonMain/kotlin/com/flipcash/app/startup/contract/StartupContract.kt @@ -32,11 +32,12 @@ object StartupContract { /** `SessionListener` implementations desktop must bind as well. Fully qualified class names. */ val sharedSessionListeners: Set = setOf( "com.flipcash.app.tokens.TokenCoordinator", + // Without it the feed never syncs, so the chat list stays empty. + "com.flipcash.shared.chat.internal.RealChatCoordinator", ) /** `SessionListener` implementations only Android binds, with why desktop goes without. */ val androidOnlySessionListeners: Map = mapOf( - "com.flipcash.shared.chat.internal.RealChatCoordinator" to "chat is not on desktop", "com.flipcash.app.contacts.ContactCoordinator" to "contacts are not on desktop", )