From 06a7e54fbafe63f2c8cb68b8f29cbc1efa2e8bc7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:43:24 -0400 Subject: [PATCH 01/14] refactor(myaccount): reorganize account & profile screens into subpackages Move MyAccount and UserProfile screen/viewmodel files into internal/myaccount and internal/userprofile subpackages and update imports/tests accordingly. --- .../com/flipcash/app/myaccount/MyAccountScreen.kt | 12 +++++++++--- .../flipcash/app/myaccount/UserProfileScreen.kt | 4 ++-- .../{ => myaccount}/MyAccountMenuItems.kt | 11 ++++++++++- .../{ => myaccount}/MyAccountScreenContent.kt | 2 +- .../{ => myaccount}/MyAccountScreenViewModel.kt | 15 +++++++++++++-- .../{ => userprofile}/UserProfileScreenContent.kt | 2 +- .../{ => userprofile}/UserProfileViewModel.kt | 2 +- .../internal/ContactMethodsViewModelStateTest.kt | 1 + .../internal/MyAccountScreenViewModelStateTest.kt | 5 +++++ .../internal/UserProfileScreenContentTest.kt | 2 ++ 10 files changed, 45 insertions(+), 11 deletions(-) rename apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/{ => myaccount}/MyAccountMenuItems.kt (81%) rename apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/{ => myaccount}/MyAccountScreenContent.kt (93%) rename apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/{ => myaccount}/MyAccountScreenViewModel.kt (93%) rename apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/{ => userprofile}/UserProfileScreenContent.kt (99%) rename apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/{ => userprofile}/UserProfileViewModel.kt (99%) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index b5db14823..007d6db21 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -5,14 +5,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute -import com.flipcash.app.myaccount.internal.MyAccountScreen -import com.flipcash.app.myaccount.internal.MyAccountScreenViewModel +import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen +import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults @@ -75,4 +74,11 @@ fun MyAccountScreen() { .onEach { navigator.push(AppRoute.Menu.UserProfile) } .launchIn(this) } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.push(AppRoute.Menu.Blocklist) } + .launchIn(this) + } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index c4dae753c..8a02355d0 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute -import com.flipcash.app.myaccount.internal.UserProfileScreenContent -import com.flipcash.app.myaccount.internal.UserProfileViewModel +import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent +import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt similarity index 81% rename from apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt rename to apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index bc9a76ec8..4d6c3ad96 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -1,7 +1,8 @@ -package com.flipcash.app.myaccount.internal +package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContactMail +import androidx.compose.material.icons.outlined.Block import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -22,6 +23,14 @@ internal data object AccessKey : FullMenuItem() override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnAccessKeyClicked } +internal data object Blocklist : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Block) + override val name: String + @Composable get() = stringResource(R.string.title_blocklist) + override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnBlocklistClicked +} + internal data object UserProfile : StaffMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.ContactMail) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt similarity index 93% rename from apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt rename to apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt index 45e75931f..99973aa36 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.myaccount.internal +package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt similarity index 93% rename from apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt rename to apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index ddf0d9457..d5815d7b4 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.myaccount.internal +package com.flipcash.app.myaccount.internal.myaccount import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager @@ -24,6 +24,7 @@ import javax.inject.Inject private val FullMenuList = buildList { add(AccessKey) + add(Blocklist) add(UserProfile) add(LogOut) add(DeleteAccount) @@ -49,7 +50,9 @@ internal class MyAccountScreenViewModel @Inject constructor( internal sealed interface Event { data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event data object OnAccessKeyClicked : Event + data object OnBlocklistClicked: Event data object OnViewAccessKey : Event + data object OnViewBlocklist: Event data object OnContactMethodsClicked : Event data object OnViewUserProfile : Event data object OnDeleteAccountClicked : Event @@ -110,6 +113,12 @@ internal class MyAccountScreenViewModel @Inject constructor( ) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent(Event.OnViewBlocklist) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -164,7 +173,9 @@ internal class MyAccountScreenViewModel @Inject constructor( Event.OnViewAccessKey, Event.OnDeleteAccountClicked, Event.OnAccountDeleted, - Event.OnAccessKeyClicked -> { state -> state } + Event.OnAccessKeyClicked, + Event.OnBlocklistClicked, + Event.OnViewBlocklist -> { state -> state } is Event.OnBetaFeaturesUnlocked -> { state -> state.copy( diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt similarity index 99% rename from apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt rename to apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt index 95a4123b0..830314dff 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.myaccount.internal +package com.flipcash.app.myaccount.internal.userprofile import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt similarity index 99% rename from apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt rename to apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt index 560300f9c..995fd573c 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.myaccount.internal +package com.flipcash.app.myaccount.internal.userprofile import android.content.ClipboardManager import androidx.lifecycle.viewModelScope diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt index 1ebb43085..16f1eccc9 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.myaccount.internal +import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.VerifiableContactMethod import kotlin.test.Test diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index 3c54560e6..7413e8d5e 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -1,5 +1,10 @@ package com.flipcash.app.myaccount.internal +import com.flipcash.app.myaccount.internal.myaccount.AccessKey +import com.flipcash.app.myaccount.internal.myaccount.DeleteAccount +import com.flipcash.app.myaccount.internal.myaccount.LogOut +import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel +import com.flipcash.app.myaccount.internal.myaccount.UserProfile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt index f50d6ffca..7d214d1a1 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent +import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.VerifiableContactMethod import com.getcode.theme.DesignSystem From 95563a7967c5beb5e6d466700a898a65129d8a9f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:43:25 -0400 Subject: [PATCH 02/14] feat(blocklist): cache blocked users in Room (db v25) Add a blocked_users table (BlockedUserEntity + BlockedUserDao, incl. an atomic replaceAll) storing each blocked user's id, blocked-at time, and a minimal resolved display profile so the list renders offline. Bump FlipcashDatabase to v25 via an additive AutoMigration. --- .../25.json | 697 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 7 +- .../app/persistence/dao/BlockedUserDao.kt | 41 ++ .../persistence/entities/BlockedUserEntity.kt | 21 + 4 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/25.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/25.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/25.json new file mode 100644 index 000000000..5a0ff2823 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/25.json @@ -0,0 +1,697 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "178ef73c0859c1316fea1f0e4327d7cd", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, `user_profile_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '178ef73c0859c1316fea1f0e4327d7cd')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 9a614f43e..3306a1cec 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -13,6 +13,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters +import com.flipcash.app.persistence.dao.BlockedUserDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao import com.flipcash.app.persistence.dao.ChatMetadataDao @@ -20,6 +21,7 @@ import com.flipcash.app.persistence.dao.ContactDao import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao +import com.flipcash.app.persistence.entities.BlockedUserEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -47,6 +49,7 @@ import com.getcode.utils.subByteArray ChatMetadataEntity::class, ChatMessageEntity::class, ChatMemberEntity::class, + BlockedUserEntity::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class), @@ -72,8 +75,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23, spec = FlipcashDatabase.Migration22To23::class), AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25), ], - version = 24, + version = 25, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -85,6 +89,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMetadataDao(): ChatMetadataDao abstract fun chatMessageDao(): ChatMessageDao abstract fun chatMemberDao(): ChatMemberDao + abstract fun blockedUserDao(): BlockedUserDao class Migration1To2 : Migration(1, 2), AutoMigrationSpec { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt new file mode 100644 index 000000000..447aa4738 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/BlockedUserDao.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.persistence.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.flipcash.app.persistence.entities.BlockedUserEntity + +@Dao +interface BlockedUserDao { + + /** Blocklist ordered most-recently-blocked first, matching the server's ordering. */ + @Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC") + fun observePaged(): PagingSource + + @Query("SELECT * FROM blocked_users ORDER BY blocked_at_epoch_ms DESC") + suspend fun getAll(): List + + @Transaction + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entities: List) + + /** + * Atomically replaces the whole blocklist (used by a paging REFRESH). Doing the clear + insert + * in one transaction means Room fires a single invalidation with the final rows — so observers + * never momentarily see an empty table between the delete and the insert. + */ + @Transaction + suspend fun replaceAll(entities: List) { + deleteAll() + upsert(entities) + } + + @Query("DELETE FROM blocked_users WHERE user_id_hex = :userIdHex") + suspend fun delete(userIdHex: String) + + @Query("DELETE FROM blocked_users") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt new file mode 100644 index 000000000..e82647df9 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/BlockedUserEntity.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.flipcash.app.persistence.converters.UserProfileSerialized + +/** + * A single user on the current account's blocklist, cached for offline display. + * + * The server's blocklist entry only carries the user id + when they were blocked, so the + * display profile ([userProfileJson], resolved separately when the page is fetched) is embedded + * here — mirroring how [ChatMemberEntity] embeds a member's profile — so the list renders name + + * avatar without a per-row network lookup. + */ +@Entity(tableName = "blocked_users") +data class BlockedUserEntity( + @PrimaryKey @ColumnInfo(name = "user_id_hex") val userIdHex: String, + @ColumnInfo(name = "blocked_at_epoch_ms") val blockedAtEpochMs: Long, + @ColumnInfo(name = "user_profile_json") val userProfileJson: UserProfileSerialized?, +) From 4f8db21f36c64a2cd4f7888083a55ff8938ab363 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:43:49 -0400 Subject: [PATCH 03/14] feat(blocklist): paged RemoteMediator + offline data source Add BlockedUserDataSource and BlocklistRemoteMediator: page the server blocklist (opaque cursor, atomic replaceAll on refresh), resolve each user's display profile via ProfileController, and cache into Room as the single source of truth. One-way Mapper<> impls convert between server model, entity, and the new core BlockedUserProfile. --- .../app/core/blocklist/BlockedUserProfile.kt | 22 +++++ .../sources/BlockedUserDataSource.kt | 52 +++++++++++ .../BlockedUserEntityToProfileMapper.kt | 30 +++++++ .../blocklist/BlockedUserToEntityMapper.kt | 39 +++++++++ .../mediator/BlocklistRemoteMediator.kt | 86 +++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt new file mode 100644 index 000000000..d76833a35 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt @@ -0,0 +1,22 @@ +package com.flipcash.app.core.blocklist + +import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * A blocklist entry enriched with the display profile needed to render it — the server's blocklist + * entry only carries the user id + when they were blocked, so the name/avatar are resolved + * separately (via the profile service) and carried alongside for the blocklist UI. + * + * @param userId The blocked user + * @param displayName Resolved display name, or empty if the profile could not be resolved + * @param profilePicture Resolved avatar, or null if unset/unresolved + * @param blockedAt When the user was blocked + */ +data class BlockedUserProfile( + val userId: ID, + val displayName: String, + val profilePicture: MediaItem?, + val blockedAt: Instant, +) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt new file mode 100644 index 000000000..adb814559 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/BlockedUserDataSource.kt @@ -0,0 +1,52 @@ +package com.flipcash.app.persistence.sources + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserEntityToProfileMapper +import com.flipcash.app.persistence.sources.mapper.blocklist.BlockedUserToEntityMapper +import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BlockedUserDataSource @Inject constructor( + private val toEntityMapper: BlockedUserToEntityMapper, + private val toProfileMapper: BlockedUserEntityToProfileMapper, +) { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + /** Room-backed paging source for the cached blocklist; empty until the DB is initialized. */ + fun observe(): PagingSource { + return db?.blockedUserDao()?.observePaged() ?: object : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + override suspend fun load(params: LoadParams): LoadResult = + LoadResult.Error(IllegalStateException("Database not initialized")) + } + } + + fun toProfile(entity: BlockedUserEntity): BlockedUserProfile = toProfileMapper.map(entity) + + suspend fun upsert(resolved: List) { + db?.blockedUserDao()?.upsert(resolved.map { toEntityMapper.map(it) }) + } + + /** Atomically replaces the cached blocklist — a single Room invalidation, no empty flicker. */ + suspend fun replaceAll(resolved: List) { + db?.blockedUserDao()?.replaceAll(resolved.map { toEntityMapper.map(it) }) + } + + suspend fun clear() { + db?.blockedUserDao()?.deleteAll() + } + + suspend fun delete(userId: ID) { + db?.blockedUserDao()?.delete(userId.hexEncodedString()) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt new file mode 100644 index 000000000..1dbaa1b0b --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.persistence.sources.mapper.blocklist + +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.mapper.Mapper +import javax.inject.Inject +import kotlin.time.Instant + +class BlockedUserEntityToProfileMapper @Inject constructor() : + Mapper { + + override fun map(from: BlockedUserEntity): BlockedUserProfile = BlockedUserProfile( + userId = from.userIdHex.hexToId(), + displayName = from.userProfileJson?.displayName.orEmpty(), + profilePicture = from.userProfileJson?.profilePicture, + blockedAt = Instant.fromEpochMilliseconds(from.blockedAtEpochMs), + ) + + private fun String.hexToId(): ID { + val data = ByteArray(length / 2) + var i = 0 + while (i < length) { + data[i / 2] = + ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte() + i += 2 + } + return data.toList() + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt new file mode 100644 index 000000000..315ae9058 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserToEntityMapper.kt @@ -0,0 +1,39 @@ +package com.flipcash.app.persistence.sources.mapper.blocklist + +import com.flipcash.app.persistence.converters.UserProfileSerialized +import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.services.models.BlockedUser +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.mapper.Mapper +import com.getcode.utils.hexEncodedString +import javax.inject.Inject + +/** + * A server blocklist entry paired with its separately-resolved display [profile]. The server only + * returns [BlockedUser] (id + timestamp); the profile is fetched alongside so the row can be cached + * fully renderable. + */ +data class ResolvedBlockedUser( + val blocked: BlockedUser, + val profile: UserProfile?, +) + +class BlockedUserToEntityMapper @Inject constructor() : + Mapper { + + override fun map(from: ResolvedBlockedUser): BlockedUserEntity = BlockedUserEntity( + userIdHex = from.blocked.userId.hexEncodedString(), + blockedAtEpochMs = from.blocked.blockedAt.toEpochMilliseconds(), + // Only the fields the blocklist row renders (name + avatar) are persisted; the rest of the + // profile is intentionally dropped. + userProfileJson = from.profile?.let { + UserProfileSerialized( + displayName = it.displayName, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + profilePicture = it.profilePicture, + ) + }, + ) +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt new file mode 100644 index 000000000..797d8c75d --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mediator/BlocklistRemoteMediator.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.persistence.sources.mediator + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.sources.BlockedUserDataSource +import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser +import com.flipcash.services.controllers.BlocklistController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.QueryOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext + +/** + * Drives the offline-first blocklist: fetches server pages via [BlocklistController], resolves each + * blocked user's display profile via [ProfileController] (the blocklist endpoint returns only id + + * timestamp), and writes them into Room ([dataSource]) where the [BlockedUserDao] paging source is + * the single source of truth the UI observes. + * + * The server cursor ([PagingToken]) is opaque and not derivable from the last row, so it's held in + * memory across APPENDs and reset on REFRESH. A fresh Pager therefore re-runs REFRESH from page 1 + * (default [initialize] behavior) — cheap for a typically-small blocklist, and Room still serves + * cached rows instantly while it revalidates. + */ +@OptIn(ExperimentalPagingApi::class) +class BlocklistRemoteMediator( + private val controller: BlocklistController, + private val profileController: ProfileController, + private val dataSource: BlockedUserDataSource, +) : RemoteMediator() { + + private var nextToken: PagingToken? = null + + override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + return try { + val token = when (loadType) { + LoadType.REFRESH -> { + nextToken = null + null + } + + LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) + LoadType.APPEND -> nextToken + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + + val page = controller.getBlocklist( + QueryOptions(limit = state.config.pageSize, token = token) + ).getOrElse { return MediatorResult.Error(it) } + + withContext(Dispatchers.IO) { + // Resolve display profiles for this page in parallel — the endpoint gives us only ids. + val resolved = coroutineScope { + page.users.map { blocked -> + async { + val profile = profileController.getProfileForUser(blocked.userId).getOrNull() + ResolvedBlockedUser(blocked, profile) + } + }.awaitAll() + } + + // REFRESH replaces the list atomically (single Room invalidation) so the UI never + // sees an empty table between clearing old rows and inserting the fresh page. + if (loadType == LoadType.REFRESH) { + dataSource.replaceAll(resolved) + } else { + dataSource.upsert(resolved) + } + } + + nextToken = page.pagingToken + MediatorResult.Success(endOfPaginationReached = !page.hasMore) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } +} From d6da1903369e4e156af197ef274c01b20b85ab18 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:43:49 -0400 Subject: [PATCH 04/14] feat(chat): hide chats from the feed, toggleable locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter ChatMetadata.isHidden out of FeedOperations.feed so hidden DMs (e.g. a blocked chat) drop out of the Tips/Contact feeds, and add ChatCoordinator.setChatHidden (DAO updateHidden -> datasource setHidden) so blocking/unblocking can flip it optimistically — the feed, which observes Room, updates live without a server round-trip. --- .../kotlin/com/flipcash/shared/chat/ChatCoordinator.kt | 7 +++++++ .../shared/chat/internal/delegates/FeedSyncDelegate.kt | 8 ++++++++ .../com/flipcash/app/persistence/dao/ChatMetadataDao.kt | 3 +++ .../app/persistence/sources/ChatMetadataDataSource.kt | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 4d36849bc..13bd6c149 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -31,6 +31,13 @@ interface FeedOperations { /** Triggers a server-side feed sync. Safe to call redundantly. */ fun refreshFeed() + + /** + * Locally sets [chatId]'s hidden flag so it drops out of / returns to the feed immediately + * (optimistic — e.g. right after blocking or unblocking the other member), without waiting for + * the next server sync. + */ + suspend fun setChatHidden(chatId: ChatId, hidden: Boolean) } /** diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 54b248d33..da7cb2951 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -83,6 +83,8 @@ class FeedSyncDelegate @Inject constructor( } state.feed .filter { it.type == chatType } + // Hidden chats (e.g. a DM the user blocked) must not surface in the feed. + .filter { !it.isHidden } .mapNotNull { metadata -> val otherMember = metadata.members.firstOrNull { !isSelf(it) } ?: return@mapNotNull null @@ -118,6 +120,12 @@ class FeedSyncDelegate @Inject constructor( syncFeed() } + override suspend fun setChatHidden(chatId: ChatId, hidden: Boolean) { + // Persist the hidden flag; observeFeedFromDb re-emits off the Room change, so the feed + // (filtered by isHidden) updates live without a server round-trip. + metadataDataSource.setHidden(chatId, hidden = hidden) + } + // endregion // region Internal diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index fb1512e30..97951c944 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -37,6 +37,9 @@ interface ChatMetadataDao { @Query("SELECT latest_event_sequence FROM chat_metadata WHERE chat_id_hex = :chatIdHex") suspend fun getLatestEventSequence(chatIdHex: String): Long? + @Query("UPDATE chat_metadata SET is_hidden = :hidden WHERE chat_id_hex = :chatIdHex") + suspend fun updateHidden(chatIdHex: String, hidden: Boolean) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 5f24110d7..7f51deab3 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -50,6 +50,10 @@ class ChatMetadataDataSource @Inject constructor( suspend fun getLatestEventSequence(chatId: ChatId): Long = db?.chatMetadataDao()?.getLatestEventSequence(mapper.chatIdHex(chatId)) ?: 0L + suspend fun setHidden(chatId: ChatId, hidden: Boolean) { + db?.chatMetadataDao()?.updateHidden(mapper.chatIdHex(chatId), hidden) + } + suspend fun exists(chatId: ChatId): Boolean = db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId)) != null From 962106f4cd44dbf10f37f7d2ce4af7b7c47208f6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:43:49 -0400 Subject: [PATCH 05/14] feat(ui): slot-driven end content for MenuList/ListItem Add ListItem and MenuList overloads that let the caller drive the trailing endSlot (chevron, loading spinner, etc.) instead of a fixed chevron, so a row can show per-item state. Existing overloads are unchanged. --- .../kotlin/com/flipcash/app/menu/MenuList.kt | 43 ++++++++++++++++++ .../com/getcode/ui/components/ListItem.kt | 45 +++++++++++++++---- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt index c6056e90e..792f503b4 100644 --- a/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt +++ b/apps/flipcash/shared/menu/src/main/kotlin/com/flipcash/app/menu/MenuList.kt @@ -1,6 +1,7 @@ package com.flipcash.app.menu import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -47,6 +48,48 @@ fun MenuList( } } +/** + * Slot-driven variant: the caller renders each row's trailing content via [endSlot] (chevron, + * loading spinner, etc.). Use this when a row needs a stateful trailing indicator. + */ +@Composable +fun MenuList( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + items: List>, + header: @Composable (() -> Unit)? = null, + footer: @Composable (() -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + onItemClick: (MenuItem) -> Unit, + endSlot: @Composable RowScope.(MenuItem) -> Unit, +) { + LazyColumn( + modifier = modifier + .verticalScrollStateGradient( + scrollState = state, + isLongGradient = true, + ).sheetResignmentBehavior(state), + state = state, + contentPadding = contentPadding, + ) { + if (header != null) { + item { header() } + } + items(items, key = { it.id }, contentType = { it }) { item -> + ListItem( + headline = item.name, + icon = item.icon, + modifier = Modifier.animateItem(), + onClick = { onItemClick(item) }, + endSlot = { endSlot(item) }, + ) + } + if (footer != null) { + item { footer() } + } + } +} + @Composable private fun ListItem( modifier: Modifier = Modifier, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt index 82e2d3473..30e20298e 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ListItem.kt @@ -2,6 +2,7 @@ package com.getcode.ui.components import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,14 +23,18 @@ import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import androidx.compose.foundation.clickable +/** + * Slot-based list row: icon + headline, with the caller driving the trailing [endSlot] — chevron, + * loading spinner, beta badge, or any combination. Prefer this overload when the trailing content + * is stateful (e.g. swaps to a spinner while the row's action is in flight). + */ @Composable fun ListItem( headline: String, icon: Painter?, modifier: Modifier = Modifier, - showBetaIndicator: Boolean = false, - showChevron: Boolean = true, onClick: () -> Unit, + endSlot: @Composable RowScope.() -> Unit, ) { Row( modifier = modifier @@ -62,6 +67,34 @@ fun ListItem( Spacer(Modifier.weight(1f)) + endSlot() + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + color = CodeTheme.colors.divider, + thickness = 0.5.dp + ) +} + +/** + * Convenience row with the standard trailing content: an optional beta badge and a chevron. + */ +@Composable +fun ListItem( + headline: String, + icon: Painter?, + modifier: Modifier = Modifier, + showBetaIndicator: Boolean = false, + showChevron: Boolean = true, + onClick: () -> Unit, +) { + ListItem( + headline = headline, + icon = icon, + modifier = modifier, + onClick = onClick, + ) { if (showBetaIndicator) { BetaIndicator() } @@ -78,10 +111,4 @@ fun ListItem( ) } } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - color = CodeTheme.colors.divider, - thickness = 0.5.dp - ) -} \ No newline at end of file +} From 91dad567d0c3967d7e11f98ff85ed5b5f78c7264 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:44:08 -0400 Subject: [PATCH 06/14] feat(blocklist): BlocklistCoordinator module + launch/resume sync New :apps:flipcash:shared:blocklist module with an auth-gated (deduped) Pager coordinator that owns block/unblock (both toggle the chat's hidden flag) and a one-shot refresh(). RealSessionController.onAppInForeground now calls blocklistCoordinator.refresh() so the local blocklist stays current on launch/resume without opening the screen. --- .../shared/blocklist/build.gradle.kts | 17 +++ .../app/blocklist/BlocklistCoordinator.kt | 104 ++++++++++++++++++ apps/flipcash/shared/session/build.gradle.kts | 1 + .../session/internal/RealSessionController.kt | 11 ++ settings.gradle.kts | 1 + 5 files changed, 134 insertions(+) create mode 100644 apps/flipcash/shared/blocklist/build.gradle.kts create mode 100644 apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt diff --git a/apps/flipcash/shared/blocklist/build.gradle.kts b/apps/flipcash/shared/blocklist/build.gradle.kts new file mode 100644 index 000000000..ef985b5df --- /dev/null +++ b/apps/flipcash/shared/blocklist/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.blocklist" +} + +dependencies { + implementation(libs.bundles.room) + implementation(libs.androidx.paging.runtime) + + compileOnly(project(":apps:flipcash:shared:persistence:db")) + implementation(project(":apps:flipcash:shared:persistence:sources")) + implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":services:flipcash")) +} diff --git a/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt new file mode 100644 index 000000000..f141305b1 --- /dev/null +++ b/apps/flipcash/shared/blocklist/src/main/kotlin/com/flipcash/app/blocklist/BlocklistCoordinator.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.blocklist + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.app.persistence.sources.BlockedUserDataSource +import com.flipcash.app.persistence.sources.mapper.blocklist.ResolvedBlockedUser +import com.flipcash.app.persistence.sources.mediator.BlocklistRemoteMediator +import com.flipcash.services.controllers.BlocklistController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the offline-first blocklist stream and unblock action for the account's blocklist screen. + * + * Mirrors [com.flipcash.app.activityfeed.ActivityFeedCoordinator]: gated on an authenticated + * session, it wires a [Pager] + [BlocklistRemoteMediator] over the Room-backed + * [BlockedUserDataSource] and maps cached entities to display [BlockedUserProfile]s. + */ +@Singleton +class BlocklistCoordinator @Inject constructor( + private val blocklistController: BlocklistController, + private val profileController: ProfileController, + private val dataSource: BlockedUserDataSource, + private val chatCoordinator: ChatCoordinator, + private val userManager: UserManager, +) { + private val pagingConfig = PagingConfig(pageSize = 20) + + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) + val blocklist: Flow> = userManager.state + // Dedupe the auth gate so the Pager is built ONCE. Without this, every unrelated + // userManager.state emission re-passes the filter and flatMapLatest rebuilds the Pager — + // each new PagingData resets the list to 0 items, flashing the empty state. + .map { it.authState.canAccessAuthenticatedApis } + .distinctUntilChanged() + .filter { it } + .flatMapLatest { + Pager( + config = pagingConfig, + remoteMediator = BlocklistRemoteMediator( + blocklistController, + profileController, + dataSource, + ), + ) { + dataSource.observe() + }.flow.map { page -> page.map { entity -> dataSource.toProfile(entity) } } + } + + /** Blocks [userId] and hides the DM so it drops out of the Tips feed immediately. */ + suspend fun blockUser(userId: ID): Result = + blocklistController.blockUser(userId).onSuccess { + // TIP_DM ids are derivable, so no network lookup is needed to find the chat to hide. + chatCoordinator.generateChatId(userId).getOrNull() + ?.let { chatCoordinator.setChatHidden(it, hidden = true) } + } + + /** Unblocks [userId], removing the cached row so the list reflects it immediately. */ + suspend fun unblock(userId: ID): Result = + blocklistController.unblockUser(userId).onSuccess { + dataSource.delete(userId) + // Inverse of the hide-on-block: restore the DM so it reappears in the Tips feed. + chatCoordinator.generateChatId(userId).getOrNull() + ?.let { chatCoordinator.setChatHidden(it, hidden = false) } + } + + /** + * Pulls the latest blocklist from the server and atomically replaces the local cache. Meant to + * run on app launch/resume so the list stays current — including blocks/unblocks made on other + * devices — without needing the blocklist screen to be opened. + */ + suspend fun refresh(): Result = runCatching { + val page = blocklistController.getBlocklist(QueryOptions()).getOrThrow() + val resolved = coroutineScope { + page.users.map { blocked -> + async { + ResolvedBlockedUser( + blocked = blocked, + profile = profileController.getProfileForUser(blocked.userId).getOrNull(), + ) + } + }.awaitAll() + } + dataSource.replaceAll(resolved) + } +} diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index f0b4689ff..7e6f54817 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { testImplementation(testFixtures(project(":ui:resources"))) implementation(project(":apps:flipcash:shared:blob")) + implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:activityfeed")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 5b100350d..a441c4fac 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -5,6 +5,7 @@ import com.flipcash.app.activityfeed.ActivityFeedUpdater import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient +import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.services.models.chat.ChatType @@ -106,6 +107,7 @@ class RealSessionController @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, + private val blocklistCoordinator: BlocklistCoordinator, private val blobStorageCoordinator: BlobStorageCoordinator, networkObserver: NetworkConnectivityListener, featureFlagController: FeatureFlagController, @@ -308,6 +310,7 @@ class RealSessionController @Inject constructor( updateSettings() checkPendingItemsInFeed() bringActivityFeedCurrent() + refreshBlocklist() shareSheetController.checkForShare() if (userManager.authState.isAtLeastRegistered && userManager.state.value.flags?.requiresIapForRegistration == true) { billingClient.connect() @@ -413,4 +416,12 @@ class RealSessionController @Inject constructor( } } } + + private fun refreshBlocklist() { + if (userManager.authState.canAccessAuthenticatedApis) { + scope.launch { + blocklistCoordinator.refresh() + } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 872d6660a..a6d63b0d6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,6 +51,7 @@ include( ":apps:flipcash:shared:appsettings", ":apps:flipcash:shared:authentication", ":apps:flipcash:shared:activityfeed", + ":apps:flipcash:shared:blocklist", ":apps:flipcash:shared:bills", ":apps:flipcash:shared:bill-customization", ":apps:flipcash:shared:chat", From da02bec891c8d67a3aab20587250a5039ffb49cc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:44:08 -0400 Subject: [PATCH 07/14] feat(blocklist): blocked-users screen Blocked-users list: an 'Blocked' app bar over a paged LazyColumn of blocked users (blurred BlurHash avatar + name), tap-to-unblock with a confirmation and a per-row spinner (LoadingSuccessState), a debounced 'No One Blocked' empty state, and a blurred mode added to ContactAvatar. --- .../features/myaccount/build.gradle.kts | 4 + .../flipcash/app/myaccount/BlocklistScreen.kt | 46 +++++ .../blocklist/BlocklistScreenContent.kt | 172 ++++++++++++++++++ .../internal/blocklist/BlocklistViewModel.kt | 108 +++++++++++ .../shared/common/ui/ContactAvatar.kt | 23 +++ 5 files changed, 353 insertions(+) create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/BlocklistScreen.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index e661d7507..456f129f8 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -11,13 +11,17 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.bundles.compose.ui.testing) + implementation(libs.compose.paging) + implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) implementation(project(":libs:datetime")) + implementation(project(":libs:encryption:utils")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/BlocklistScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/BlocklistScreen.kt new file mode 100644 index 000000000..19a6c704f --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/BlocklistScreen.kt @@ -0,0 +1,46 @@ +package com.flipcash.app.myaccount + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.myaccount.internal.blocklist.BlocklistScreenContent +import com.flipcash.app.myaccount.internal.blocklist.BlocklistViewModel +import com.flipcash.core.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle + +@Composable +fun BlocklistScreen() { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val blocked = viewModel.blocked.collectAsLazyPagingItems() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { + AppBarDefaults.Title(text = stringResource(R.string.title_blocklist)) + }, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + BlocklistScreenContent( + blocked = blocked, + unblocking = state.unblocking, + onUnblock = { + viewModel.dispatchEvent(BlocklistViewModel.Event.UnblockRequested(it)) + }, + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt new file mode 100644 index 000000000..10e5a2c06 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt @@ -0,0 +1,172 @@ +package com.flipcash.app.myaccount.internal.blocklist + +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey +import kotlinx.coroutines.delay +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.features.myaccount.R +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.utils.hexEncodedString +import com.getcode.view.LoadingSuccessState +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun BlocklistScreenContent( + blocked: LazyPagingItems, + unblocking: Map, + onUnblock: (BlockedUserProfile) -> Unit, +) { + // Empty-state detection with a RemoteMediator is race-prone: after the mediator's refresh + // finishes there's a frame where it reports NotLoading + 0 items *before* the Room PagingSource + // re-queries and surfaces the freshly-inserted rows (the replaceAll invalidation → source + // reload is async). The same transient shows up on the very first frame (source settled empty, + // mediator not yet fetching). No instantaneous snapshot can tell that gap apart from a truly + // empty list — so debounce it: only a "settled empty" that survives a short window paints + // "No One Blocked". A transient gap resolves to data first and never flashes. + val loadState = blocked.loadState + val settledEmpty = loadState.refresh is LoadState.NotLoading && + loadState.mediator?.refresh is LoadState.NotLoading && + loadState.append.endOfPaginationReached && + blocked.itemCount == 0 + var isEmpty by remember { mutableStateOf(false) } + LaunchedEffect(settledEmpty) { + isEmpty = if (settledEmpty) { + delay(250.milliseconds) + true + } else { + false + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isEmpty) { + EmptyBlocklist(modifier = Modifier.align(Alignment.Center)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset), + ) { + items( + count = blocked.itemCount, + key = blocked.itemKey { it.userId.hexEncodedString() }, + ) { index -> + val user = blocked[index] ?: return@items + BlockedUserRow( + modifier = Modifier.animateItem(), + user = user, + loadingState = unblocking[user.userId.hexEncodedString()] + ?: LoadingSuccessState(), + onClick = { onUnblock(user) }, + ) + } + } + } + } +} + +@Composable +private fun BlockedUserRow( + user: BlockedUserProfile, + loadingState: LoadingSuccessState, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + // Only actionable while idle — no re-taps mid-unblock. + .clickable(enabled = loadingState.isIdle, onClick = onClick) + .padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = user.profilePicture, + displayName = user.displayName, + // Blocked users are shown obscured, per the design. + blurred = true, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + ) + Text( + modifier = Modifier.weight(1f), + text = user.displayName, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Fixed footprint so the row doesn't reflow when the chevron becomes a spinner. + Box( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + contentAlignment = Alignment.Center, + ) { + if (loadingState.state == LoadingSuccessState.State.Loading) { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.grid.x3), + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } + } +} + +@Composable +private fun EmptyBlocklist(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_blocklistEmpty), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = stringResource(R.string.description_blocklistEmpty), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt new file mode 100644 index 000000000..83e6698a4 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt @@ -0,0 +1,108 @@ +package com.flipcash.app.myaccount.internal.blocklist + +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.flipcash.app.blocklist.BlocklistCoordinator +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.core.R +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.hexEncodedString +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +internal class BlocklistViewModel @Inject constructor( + private val coordinator: BlocklistCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + /** The paged, offline-first blocklist, cached across recompositions/config changes. */ + val blocked: Flow> = + coordinator.blocklist.cachedIn(viewModelScope) + + /** + * @param unblocking per-user unblock progress, keyed by user id hex, so an in-flight row can + * swap its chevron for a spinner. + */ + data class State(val unblocking: Map = emptyMap()) + + sealed interface Event { + /** User tapped a row — prompt for confirmation before unblocking. */ + data class UnblockRequested(val user: BlockedUserProfile) : Event + + /** User confirmed the unblock prompt. */ + data class UnblockConfirmed(val user: BlockedUserProfile) : Event + + /** Progress of the unblock request for [userIdHex]. */ + data class UnblockProcessing( + val userIdHex: String, + val loading: Boolean = false, + val success: Boolean = false, + val error: Boolean = false, + ) : Event + } + + init { + eventFlow + .filterIsInstance() + .onEach { event -> + BottomBarManager.showAlert( + title = resources.getString( + R.string.prompt_title_unblockUser, + event.user.displayName, + ), + message = resources.getString(R.string.prompt_description_unblockUser), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_unblock)) { + dispatchEvent(Event.UnblockConfirmed(event.user)) + } + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { event -> + val key = event.user.userId.hexEncodedString() + dispatchEvent(Event.UnblockProcessing(key, loading = true)) + coordinator.unblock(event.user.userId) + // On success the row leaves the paged list; on failure the spinner reverts. + .onSuccess { dispatchEvent(Event.UnblockProcessing(key, success = true)) } + .onFailure { dispatchEvent(Event.UnblockProcessing(key, error = true)) } + } + .launchIn(viewModelScope) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.UnblockProcessing -> { state -> + state.copy( + unblocking = state.unblocking + ( + event.userIdHex to LoadingSuccessState( + loading = event.loading, + success = event.success, + error = event.error, + )) + ) + } + + is Event.UnblockRequested, + is Event.UnblockConfirmed -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index b6eee3026..8afa2d813 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -126,10 +127,12 @@ fun ContactAvatar( image: MediaItem?, displayName: String, modifier: Modifier = Modifier, + blurred: Boolean = false, ) { ProfileAvatar( image = image, modifier = modifier, + blurred = blurred, fallback = { InitialsText(displayName) }, ) } @@ -138,6 +141,7 @@ fun ContactAvatar( private fun ProfileAvatar( image: MediaItem?, modifier: Modifier, + blurred: Boolean = false, fallback: @Composable BoxWithConstraintsScope.() -> Unit, ) { BoxWithConstraints( @@ -145,6 +149,25 @@ private fun ProfileAvatar( Brush.linearGradient(CodeTheme.colors.contactAvatar.colors) ) ) { + if (blurred) { + // Blocked users are shown intentionally obscured — render the media item's self-contained + // BlurHash preview instead of the real image, so the avatar stays blurred on every API + // level (Modifier.blur needs API 31+) without ever fetching the sharp photo. + val blurBitmap = remember(image) { + BlurHash.decode(image?.blurhash(), width = 24, height = 24)?.asImageBitmap() + } + if (blurBitmap != null) { + Image( + bitmap = blurBitmap, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + fallback() + } + return@BoxWithConstraints + } // Pick the rendition by the avatar's actual pixel size — the longest bounded side of the // measured constraints (unbounded → request the largest, so it's never under-sized). val targetPx = remember(constraints) { From 86b5716f70f04bf679ecafa74c84084a4c5d014e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:44:46 -0400 Subject: [PATCH 08/14] feat(profile): expose user join date (join_ts) Vendor the profile.v1 UserProfile.join_ts field and thread it through the domain UserProfile as joinedAt: Instant?, mapped in UserProfileMapper. --- .../protos/src/main/proto/profile/v1/model.proto | 4 ++++ .../services/internal/domain/UserProfileMapper.kt | 4 ++++ .../com/flipcash/services/models/UserProfile.kt | 13 +++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto b/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto index fb9da21b4..47e89f9f3 100644 --- a/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto @@ -9,6 +9,7 @@ option objc_class_prefix = "FPBProfileV1"; import "blob/v1/model.proto"; import "email/v1/model.proto"; import "phone/v1/model.proto"; +import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; message UserProfile { @@ -43,6 +44,9 @@ message UserProfile { // these blobs, so a GetBlobs call must carry a blob.v1.AccessContext whose // `profile` scope names this user. A caller reading its own needs none. blob.v1.Media profile_picture = 5; + + // Timestamp the user joined Flipcash + google.protobuf.Timestamp join_ts = 6 [(validate.rules).timestamp.required = true]; } message SocialProfile { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt index 7813a76ea..3a0ee614b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.UserProfile import com.flipcash.services.models.VerifiableContactMethod import com.getcode.opencode.mapper.Mapper import javax.inject.Inject +import kotlin.time.Instant class UserProfileMapper @Inject constructor( private val socialMapper: SocialAccountMapper, @@ -20,6 +21,9 @@ class UserProfileMapper @Inject constructor( phoneNumber = from.phoneNumberOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, email = from.emailAddressOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, profilePicture = if (from.hasProfilePicture()) from.profilePicture.toMediaItem() else null, + joinedAt = if (from.hasJoinTs()) { + Instant.fromEpochSeconds(from.joinTs.seconds, from.joinTs.nanos) + } else null, ) } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index d8d9d320c..9f716c82c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -1,7 +1,13 @@ package com.flipcash.services.models +import android.os.Parcelable import com.flipcash.services.models.chat.MediaItem +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlin.time.Instant +@Parcelize +@Serializable data class UserProfile( val displayName: String, val socialAccounts: List, @@ -10,7 +16,9 @@ data class UserProfile( // The user's profile picture, as the renditions it is stored as (DISPLAY for // the profile view, THUMBNAIL for avatars). Null when unset. val profilePicture: MediaItem? = null, -) { + // When the user joined Flipcash (server-provided). Null when unknown. + val joinedAt: Instant? = null, +): Parcelable { /** The phone number only when it has been verified — backwards-compatible accessor. */ val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value @@ -27,7 +35,8 @@ data class UserProfile( } } -sealed interface SocialAccount { +@Parcelize +sealed interface SocialAccount: Parcelable { val id: String data class TwitterX( override val id: String, From 8df902dd04810d034133737002f2e42b6eb8ad28 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 12:44:46 -0400 Subject: [PATCH 09/14] feat(messenger): chat profile screen with block/unblock and join date Add the tip-DM chat profile screen (blurred-avatar header, name, joined date, and a Block action that routes through BlocklistCoordinator and returns to the Tips list on success). Moves ChatParticipant to :core, adds the ChatStep.Profile step + AppRoute.Menu.Blocklist wiring, the blocklist nav entry, and supporting chat/media model and component updates. --- .../ui/navigation/AppScreenContent.kt | 7 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 6 +- .../app/core/chat}/ChatParticipant.kt | 13 +- .../com/flipcash/app/core/chat/ChatStep.kt | 4 + .../core/src/main/res/values/strings.xml | 12 ++ .../features/messenger/build.gradle.kts | 5 + .../flipcash/app/messenger/ChatFlowScreen.kt | 56 +++++-- .../app/messenger/internal/ChatViewModel.kt | 1 + .../internal/screens/MessengerScreen.kt | 76 +++++---- .../screens/cash}/ChatAmountEntryScreen.kt | 4 +- .../screens/components/ChatBottomBar.kt | 6 +- .../internal/screens/components/ChatTopBar.kt | 11 +- .../components/ContactInfoContainer.kt | 66 +++++--- .../screens/components/MessageList.kt | 3 + .../screens/components/ParticipantAvatar.kt | 2 +- .../screens/profile/ChatProfileScreen.kt | 118 ++++++++++++++ .../screens/profile/ChatProfileViewModel.kt | 148 ++++++++++++++++++ .../screens/profile/ProfileMenuItems.kt | 19 +++ .../tipping/internal/screens/TipCardScreen.kt | 34 ++-- .../flipcash/shared/chat/models/ChatAction.kt | 1 + .../app/contacts/ContactCoordinator.kt | 7 +- services/flipcash/build.gradle.kts | 1 + .../models/VerifiableContactMethod.kt | 5 +- .../flipcash/services/models/chat/BlobId.kt | 5 +- .../services/models/chat/BlobMetadata.kt | 5 +- .../services/models/chat/ImageMetadata.kt | 5 +- .../services/models/chat/MediaItem.kt | 5 +- .../models/chat/MediaItemRendition.kt | 5 +- .../ui/components/bars/BottomBarContainer.kt | 3 +- 29 files changed, 528 insertions(+), 105 deletions(-) rename apps/flipcash/{features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal => core/src/main/kotlin/com/flipcash/app/core/chat}/ChatParticipant.kt (72%) rename apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/{ => internal/screens/cash}/ChatAmountEntryScreen.kt (96%) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 066ac4732..47b1ce242 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -35,13 +35,14 @@ import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.directsend.SendFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen -import com.flipcash.app.messenger.ChatAmountEntryScreen +import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen import com.flipcash.app.lab.NavBarSettingsScreen import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen +import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.MyAccountScreen import com.flipcash.app.scanner.ScannerScreen @@ -107,9 +108,6 @@ fun appEntryProvider( annotatedEntry { key -> ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } - annotatedEntry { key -> - ChatAmountEntryScreen(key.identifier) - } // Tokens annotatedEntry { key -> @@ -141,6 +139,7 @@ fun appEntryProvider( annotatedEntry { NavBarSettingsScreen() } annotatedEntry { UserProfileScreen() } annotatedEntry { MyAccountScreen() } + annotatedEntry { BlocklistScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { AdvancedFeaturesScreen() } annotatedEntry { DeviceLogsScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d35383c64..29838affb 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -275,6 +275,9 @@ sealed interface AppRoute : NavKey, Parcelable { data object MyAccount : Menu @Serializable data object BackupKey : Menu + + @Serializable + data object Blocklist: Menu @Serializable data object AppSettings : Menu @Serializable @@ -302,9 +305,6 @@ sealed interface AppRoute : NavKey, Parcelable { override val initialStack: List get() = listOf(ChatStep.Conversation) } - - @Serializable - data class AmountEntry(val identifier: ChatIdentifier) : Messaging } @Serializable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt similarity index 72% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt index c2159f74d..8597f582f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt @@ -1,8 +1,10 @@ -package com.flipcash.app.messenger.internal +package com.flipcash.app.core.chat +import android.os.Parcelable import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID +import kotlinx.parcelize.Parcelize /** * The counterparty a DM header and info card renders. @@ -10,12 +12,13 @@ import com.getcode.opencode.model.core.ID * A conversation is backed by one of two identity sources depending on its * [com.flipcash.services.models.chat.ChatType]: * - * - [Contact] — a `CONTACT_DM`. Identity comes from a device [DeviceContact]: it has a phone + * - [Contact] — a `CONTACT_DM`. Identity comes from a device [com.flipcash.app.core.contacts.DeviceContact]: it has a phone * number and supports the "add to contacts" action. * - [TipUser] — a `TIP_DM`. The counterparty has no device contact; identity comes from their - * server [UserProfile] (display name + profile picture), the same source the tips list uses. + * server [com.flipcash.services.models.UserProfile] (display name + profile picture), the same source the tips list uses. */ -internal sealed interface ChatParticipant { +@Parcelize +sealed interface ChatParticipant: Parcelable { val displayName: String data class Contact(val contact: DeviceContact) : ChatParticipant { @@ -25,4 +28,4 @@ internal sealed interface ChatParticipant { data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant { override val displayName: String get() = profile.displayName } -} +} \ No newline at end of file diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index db9c440d3..2f798bf8b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -19,4 +19,8 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data object AmountEntry : ChatStep, NavigationRetVal + + @Parcelize + @Serializable + data class Profile(val contact: ChatParticipant): ChatStep } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9920fbee3..7a14e2dfc 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -904,4 +904,16 @@ Enter a larger amount to send this tip via Tip Card + Block + Blocked + Block + Block %1$s? + You won’t see messages from them, but you will still receive cash they send you. Flipcash won’t tell them you blocked them + Unblock + Unblock %1$s? + The conversation with them will reappear in Tips + No One Blocked + Block people from sending you messages by tapping their profile and selecting block + Joined %1$s + \ No newline at end of file diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index aae6070ac..516415afc 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,12 +8,14 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) + implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":libs:vibrator:bindings")) @@ -23,4 +25,7 @@ dependencies { implementation(project(":libs:datetime")) implementation(libs.compose.paging) implementation(libs.bundles.haze) + + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.mockito.kotlin) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 9c658fd1c..6bb80def9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -13,22 +13,30 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.MessengerScreen +import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent +import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen +import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.results.navigateForResult import com.getcode.navigation.results.resultBackNavigator import com.getcode.navigation.scenes.LocalSheetNavigator +import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach @Composable fun ChatFlowScreen( @@ -56,6 +64,9 @@ private fun chatEntryProvider( annotatedEntry { FlowAmountEntryScreen() } + annotatedEntry { step -> + FlowChatProfileScreen(step.contact) + } } @Composable @@ -72,6 +83,7 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier)) } + val keyboard = rememberKeyboardController() var hasOpened by rememberSaveable { mutableStateOf(false) } LaunchedEffect(openKeyboard) { if (openKeyboard) { @@ -100,16 +112,18 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo viewModel.eventFlow .filterIsInstance() .collect { (route, asSheet) -> - if (asSheet) { - // Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the - // sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss) - // before opening the new one. - sheetNavigator?.openAsSheet(route) - } else { - // A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent - // chain to the outer app nav host, the same destination as the old - // outerNavigator.navigate(route). - navigator.navigate(route) + keyboard.hideIfVisible { + if (asSheet) { + // Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the + // sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss) + // before opening the new one. + sheetNavigator?.openAsSheet(route) + } else { + // A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent + // chain to the outer app nav host, the same destination as the old + // outerNavigator.navigate(route). + navigator.navigate(route) + } } } } @@ -137,3 +151,25 @@ private fun FlowAmountEntryScreen() { onExit = { navigator.navigateBack() }, // pop the AmountEntry step ) } + +@Composable +private fun FlowChatProfileScreen(participant: ChatParticipant) { + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + + LaunchedEffect(viewModel, participant) { + viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) + } + + ChatProfileScreen(viewModel) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + // Blocking removes the DM, so exit the whole chat flow (FlowHost.onExit pops the + // Chat route) and land back on the Tips list the chat was opened from. + flowNavigator.exitCanceled() + }.launchIn(this) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 7d2d542ab..b346cfef4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -13,6 +13,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.featureflags.FeatureFlag diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 6413182fe..1f5f32b66 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -11,11 +11,13 @@ import androidx.compose.ui.platform.testTag import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatActionHandler import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.hazeSource @@ -31,8 +33,49 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val hazeState = rememberHazeState() val keyboard = rememberKeyboardController() + val chatActionHandler = { action: ChatAction -> + when (action) { + is ChatAction.AdvanceReadPointer -> { + viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId)) + } + + ChatAction.RefreshContact -> { + viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) + } + + is ChatAction.RetryMessage -> { + keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.RetryMessage( + action.bubble.pendingClientIdHex, + action.bubble.content + ) + ) + } + } + + is ChatAction.ViewToken -> { + keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint)) + ) + } + } + + is ChatAction.ViewProfile -> { + state.participant?.let { + keyboard.hideIfVisible { + navigator.push(ChatStep.Profile(it)) + } + } + } + } + + Unit + } + ChatInputScaffold( - topBar = { ChatTopBar(navigator, state.participant) }, + topBar = { ChatTopBar(navigator, state.participant, chatActionHandler) }, bottomBar = { UserControlBottomBar( state = state, @@ -51,36 +94,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { messages = messages, separatorConfig = state.separatorConfig, otherReadPointer = otherReadPointer, - onAction = { action -> - when (action) { - is ChatAction.AdvanceReadPointer -> { - viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(action.messageId)) - } - - ChatAction.RefreshContact -> { - viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) - } - - is ChatAction.RetryMessage -> { - keyboard.hideIfVisible { - viewModel.dispatchEvent( - ChatViewModel.Event.RetryMessage( - action.bubble.pendingClientIdHex, - action.bubble.content - ) - ) - } - } - - is ChatAction.ViewToken -> { - keyboard.hideIfVisible { - viewModel.dispatchEvent( - ChatViewModel.Event.OpenScreen(AppRoute.Token.Info(action.mint)) - ) - } - } - } - }, + onAction = chatActionHandler, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt similarity index 96% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt rename to apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index c5348ab3a..90e6802bb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger +package com.flipcash.app.messenger.internal.screens.cash import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight @@ -18,8 +18,6 @@ import com.flipcash.shared.amountentry.AmountEntryDelegate import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.flow.LocalFlowNavigator -import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 68cb4fb7a..4e22a06a8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -160,7 +160,11 @@ internal fun UserControlBottomBar( state = state, hazeState = hazeState, hazeMaterial = material, - onClick = { dispatch(ChatViewModel.Event.OnSendCash) } + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } + } ) if (canType) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 216ec106c..282d0230d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -20,18 +20,21 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.flipcash.app.messenger.internal.ChatParticipant -import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatActionHandler import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.measured +import com.getcode.ui.core.unboundedClickable @Composable internal fun ChatTopBar( navigator: CodeNavigator, participant: ChatParticipant?, + chatActionHandler: ChatActionHandler, ) { var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background @@ -56,7 +59,9 @@ internal fun ChatTopBar( }, title = { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().unboundedClickable { + chatActionHandler(ChatAction.ViewProfile) + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index bb55f4ec4..d6c7afe63 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -33,16 +34,19 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact -import com.flipcash.app.messenger.internal.ChatParticipant +import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile import com.getcode.theme.CodeTheme +import com.getcode.ui.core.addIf @Composable internal fun ContactInfoContainer( participant: ChatParticipant?, modifier: Modifier = Modifier, + includeBorder: Boolean = true, + onOpenProfile: (() -> Unit)? = null, onRefreshContact: () -> Unit = {}, ) { // Phone number and the add-to-contacts pill only apply to a device contact; a tip DM's @@ -50,12 +54,19 @@ internal fun ContactInfoContainer( val contact = (participant as? ChatParticipant.Contact)?.contact Column( modifier = modifier - .border( - color = CodeTheme.colors.divider, - width = CodeTheme.dimens.border, - shape = CodeTheme.shapes.medium, - ) - .padding(CodeTheme.dimens.grid.x6), + .addIf(includeBorder) { + Modifier.border( + color = CodeTheme.colors.divider, + width = CodeTheme.dimens.border, + shape = CodeTheme.shapes.medium, + ) + } + .addIf(onOpenProfile != null) { + Modifier.clickable { onOpenProfile?.invoke() } + } + .addIf(includeBorder) { + Modifier.padding(CodeTheme.dimens.grid.x6) + }, horizontalAlignment = Alignment.CenterHorizontally, ) { ParticipantAvatar( @@ -64,18 +75,33 @@ internal fun ContactInfoContainer( .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), ) - Text( + + Row( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), - text = participant?.displayName.orEmpty(), - autoSize = TextAutoSize.StepBased( - minFontSize = CodeTheme.typography.textSmall.fontSize, - maxFontSize = CodeTheme.typography.textLarge.fontSize, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier, + text = participant?.displayName.orEmpty(), + autoSize = TextAutoSize.StepBased( + minFontSize = CodeTheme.typography.textSmall.fontSize, + maxFontSize = CodeTheme.typography.textLarge.fontSize, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + if (onOpenProfile != null) { + Icon( + modifier = Modifier.scale(0.8f), + painter = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } if (contact != null && !contact.isUnknown) { Text( @@ -123,7 +149,7 @@ private fun Indicator( onClick = onClick ) - else -> Spacer(modifier = modifier.fillMaxWidth()) + else -> Unit } } @@ -248,8 +274,6 @@ private fun Preview_AllStates() { ContactInfoContainer(participant = knownContact, modifier = cardWidth) ContactInfoContainer(participant = unknownContact, modifier = cardWidth) ContactInfoContainer(participant = tipUser, modifier = cardWidth) - // Null participant: loading / unknown fallback avatar, name empty, no pill. - ContactInfoContainer(participant = null, modifier = cardWidth) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index c997cfdf1..918e21bdb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -271,6 +271,9 @@ internal fun MessageList( modifier = Modifier .padding(horizontal = CodeTheme.dimens.grid.x12), onRefreshContact = { onAction(ChatAction.RefreshContact) }, + onOpenProfile = { + onAction(ChatAction.ViewProfile) + } ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt index c328a2427..bfc508417 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt @@ -2,7 +2,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.flipcash.app.messenger.internal.ChatParticipant +import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.shared.common.ui.ContactAvatar /** diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt new file mode 100644 index 000000000..d48568adf --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -0,0 +1,118 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.screens.components.ParticipantAvatar +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import com.getcode.util.DateUtils +import com.getcode.view.LoadingSuccessState +import kotlin.time.Instant + + +@Composable +internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + CodeScaffold( + topBar = { + AppBarWithTitle(backButton = true, onBackIconClicked = { flowNavigator.back() }) + }, + ) { innerPadding -> + MenuList( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + items = state.menuItems, + header = { + ProfileHeader( + participant = state.participant, + joinDate = state.joinDate, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x7), + ) + }, + onItemClick = { viewModel.dispatchEvent(it.action) }, + endSlot = { item -> + val loading = item.action == ChatProfileViewModel.Event.BlockUser && + state.processingState.state == LoadingSuccessState.State.Loading + if (loading) { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + }, + ) + } +} + +@Composable +private fun ProfileHeader( + participant: ChatParticipant?, + joinDate: Instant?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ParticipantAvatar( + participant = participant, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x17) + .clip(CircleShape), + ) + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = participant?.displayName.orEmpty(), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + joinDate?.let { instant -> + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = stringResource( + R.string.subtitle_joinedDate, + DateUtils.getDate(instant.toEpochMilliseconds(), "MMMM yyyy"), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt new file mode 100644 index 000000000..aa5c7b138 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -0,0 +1,148 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.blocklist.BlocklistCoordinator +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.menu.MenuItem +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.BlocklistController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +private val ProfileMenuItems = buildList { + add(BlockUser) +} + +@HiltViewModel +internal class ChatProfileViewModel @Inject constructor( + private val contactCoordinator: ContactCoordinator, + private val userManager: UserManager, + private val featureFlags: FeatureFlagController, + private val blocklist: BlocklistCoordinator, + private val profiles: ProfileController, + private val dispatchers: DispatcherProvider, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val participant: ChatParticipant? = null, + val joinDate: Instant? = null, + val menuItems: List> = ProfileMenuItems, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) + + sealed interface Event { + data class OnParticipantSet(val participant: ChatParticipant) : Event + data class JoinDateLoaded(val joinDate: Instant?) : Event + data object BlockUser : Event + data class BlockConfirmed(val participant: ChatParticipant.TipUser) : Event + data class BlockProcessing(val loading: Boolean = false, val success: Boolean = false): Event + data object BlockSuccessful: Event + } + + init { + eventFlow + .filterIsInstance() + .map { it.participant } + .filterIsInstance() + .distinctUntilChanged() + .onEach { (userId, profile) -> + // The cached member profile doesn't carry a join date, so resolve it from the + // server profile, falling back to whatever the participant already had. + val joinDate = profiles.getProfileForUser(userId).getOrNull()?.joinedAt + ?: profile.joinedAt + dispatchEvent(Event.JoinDateLoaded(joinDate)) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.participant } + .filterIsInstance() + .distinctUntilChanged() + .map { it.contact } + .onEach { contact -> + // TODO: + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.participant } + .filterIsInstance() + .onEach { participant -> + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_blockUser, participant.displayName), + message = resources.getString(R.string.prompt_description_blockUser), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_block), + ) { + dispatchEvent(Event.BlockConfirmed(participant)) + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.participant } + .onEach { participant -> + dispatchEvent(Event.BlockProcessing(loading = true)) + blocklist.blockUser(participant.userId) + .onSuccess { + dispatchEvent(Event.BlockProcessing(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.BlockSuccessful) + } + .onFailure { + dispatchEvent(Event.BlockProcessing()) + } + } + .launchIn(viewModelScope) + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.OnParticipantSet -> { state -> state.copy(participant = event.participant) } + is Event.JoinDateLoaded -> { state -> state.copy(joinDate = event.joinDate) } + is Event.BlockProcessing -> { state -> + val current = state.processingState + state.copy( + processingState = current.copy( + loading = event.loading, + success = event.success, + ) + ) + } + is Event.BlockUser, + is Event.BlockConfirmed, + is Event.BlockSuccessful -> { state -> state } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt new file mode 100644 index 000000000..d0a021b3a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.features.messenger.R + +internal data object BlockUser: FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Block) + + override val name: String + @Composable get() = stringResource(R.string.title_block) + override val action: ChatProfileViewModel.Event = ChatProfileViewModel.Event.BlockUser +} \ No newline at end of file diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt index f50ce234f..bd0107b8b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt @@ -46,12 +46,22 @@ internal fun TipCardScreen() { CodeScaffold( topBar = { - AppBarWithTitle( - title = stringResource(R.string.title_myTipCard), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { flowNavigator.back() }, - ) + Column( + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x10), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = stringResource(R.string.title_myTipCard), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + ) + Text( + text = stringResource(R.string.subtitle_myTipCard), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + } }, bottomBar = { Row( @@ -62,6 +72,7 @@ internal fun TipCardScreen() { verticalAlignment = Alignment.CenterVertically, ) { Column( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { @@ -98,14 +109,8 @@ internal fun TipCardScreen() { .fillMaxSize() .padding(padding), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), - text = stringResource(R.string.subtitle_myTipCard), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - Spacer(Modifier.weight(1f)) state.tipCard?.let { CompositionLocalProvider( LocalTipCardBaseAlpha provides 0.36f @@ -114,12 +119,11 @@ internal fun TipCardScreen() { // Fixed, device-independent card width matching iOS's TipcardScreen (300pt); // the card derives its QR, corner radius and avatar from this width, so both // platforms render the tip card at the same proportions. - tipCardWidth = 300.dp, + tipCardWidth = 270.dp, scannable = it, ) } } - Spacer(Modifier.weight(1f)) } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index cb5474625..117a14aa8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -8,6 +8,7 @@ sealed interface ChatAction { data class AdvanceReadPointer(val messageId: Long) : ChatAction object RefreshContact : ChatAction data class ViewToken(val mint: Mint) : ChatAction + data object ViewProfile : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index 821c997b2..a0dbfe417 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -487,7 +487,11 @@ class ContactCoordinator @Inject constructor( val adds = newE164s - existingE164s val removes = existingE164s - newE164s - // 4. Persist all mappings (upsert fixes metadata staleness for name/photo changes) + // 4. Persist all mappings (upsert fixes metadata staleness for name/photo changes). + // Preserve each existing joinedAtEpochSeconds — the upsert is a REPLACE, so omitting + // it here would zero out the server-discovered join date for known contacts. + val existingJoinedAt = existingMappings.associate { it.e164 to it.joinedAtEpochSeconds } + val allEntities = deviceContacts.values.map { contact -> ContactMappingEntity( e164 = contact.e164, @@ -495,6 +499,7 @@ class ContactCoordinator @Inject constructor( displayName = contact.displayName, photoUri = contact.photoUri, displayNumber = phoneUtils.formatNumber(contact.e164), + joinedAtEpochSeconds = existingJoinedAt[contact.e164] ?: 0L, ) } contactDataSource.upsert(allEntities) diff --git a/services/flipcash/build.gradle.kts b/services/flipcash/build.gradle.kts index 3f67dccd6..5df8adfb0 100644 --- a/services/flipcash/build.gradle.kts +++ b/services/flipcash/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.flipcash.android.library) id("com.google.devtools.ksp") + id("org.jetbrains.kotlin.plugin.parcelize") } android { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt index a5a699971..fcbf85dad 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/VerifiableContactMethod.kt @@ -1,5 +1,7 @@ package com.flipcash.services.models +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable /** @@ -7,8 +9,9 @@ import kotlinx.serialization.Serializable * verified with the server. An unverified contact is one the user entered locally * (e.g. when server verification is skipped) but which has not been confirmed. */ +@Parcelize @Serializable data class VerifiableContactMethod( val value: String, val verified: Boolean, -) +): Parcelable diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobId.kt index ac6987e4e..ee2c70bd6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobId.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobId.kt @@ -1,7 +1,10 @@ package com.flipcash.services.models.chat +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable @JvmInline -value class BlobId(val bytes: ByteArray) +value class BlobId(val bytes: ByteArray): Parcelable diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobMetadata.kt index 63e6ca02a..aee4821ec 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobMetadata.kt @@ -1,11 +1,14 @@ package com.flipcash.services.models.chat +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable data class BlobMetadata( val mimeType: String, val sizeBytes: Long, val downloadUrl: String, val image: ImageMetadata?, -) +): Parcelable diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ImageMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ImageMetadata.kt index 61e48a6f2..56b0ba177 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ImageMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ImageMetadata.kt @@ -1,10 +1,13 @@ package com.flipcash.services.models.chat +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable data class ImageMetadata( val width: Int, val height: Int, val blurhash: String, -) +): Parcelable diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt index fe108fd2a..bfc7261ad 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -1,12 +1,15 @@ package com.flipcash.services.models.chat +import android.os.Parcelable import com.getcode.utils.base58 +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable data class MediaItem( val renditions: List, -) { +): Parcelable { /** * The [preferred] rendition if it's available, otherwise the next lower-quality rendition * that is — degrading down the quality ladder ORIGINAL → DISPLAY → THUMBNAIL. Returns diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItemRendition.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItemRendition.kt index 81805edce..f355a5bfe 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItemRendition.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItemRendition.kt @@ -1,13 +1,16 @@ package com.flipcash.services.models.chat +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable data class MediaItemRendition( val role: Role, val blobId: BlobId, val blob: BlobMetadata?, -) { +): Parcelable { enum class Role { UNKNOWN, ORIGINAL, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index c8bdedf10..fe3f308f2 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -218,6 +218,7 @@ fun BottomBarView( top = CodeTheme.dimens.inset, start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset, + bottom = CodeTheme.dimens.inset, ), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), @@ -234,7 +235,7 @@ fun BottomBarView( ) if (bottomBarMessage.subtitle.isNotEmpty()) { Text( - style = CodeTheme.typography.caption, + style = CodeTheme.typography.textSmall, text = bottomBarMessage.subtitle, color = LocalContentColor.current.copy(alpha = 0.8f) ) From a4d76ae3069d19acc9c7d76cd6f56c3d02fc4fab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 13:08:24 -0400 Subject: [PATCH 10/14] test(session): pass blocklistCoordinator to RealSessionController RealSessionController gained a blocklistCoordinator constructor param (launch/ resume blocklist refresh); update the two tests that build it directly so the session unit-test sources compile. --- .../app/session/internal/SessionControllerEventRoutingTest.kt | 1 + .../app/session/internal/SessionControllerGiftCardErrorTest.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt index c4010be45..2226ad184 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt @@ -119,6 +119,7 @@ class SessionControllerEventRoutingTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), featureFlagController = featureFlagController, appSettingsCoordinator = appSettingsCoordinator, diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index ee0fecfba..b6888f13d 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -142,6 +142,7 @@ class SessionControllerGiftCardErrorTest { tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), chatCoordinator = mockk(relaxed = true), + blocklistCoordinator = mockk(relaxed = true), blobStorageCoordinator = mockk(relaxed = true), featureFlagController = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), From d642cb5c796090186e7df5ed03520526e6aa94ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 13:25:01 -0400 Subject: [PATCH 11/14] chore: remove unused imports Drop 9 unused imports left across the chat-profile and blocklist changes (ChatAmountEntryScreen, ChatActionHandler, Spacer/fillMaxWidth, BlocklistController, Box/Spacer/Scannable, DepositDelegate). --- .../flipcash/app/internal/ui/navigation/AppScreenContent.kt | 1 - .../flipcash/app/messenger/internal/screens/MessengerScreen.kt | 1 - .../internal/screens/components/ContactInfoContainer.kt | 2 -- .../messenger/internal/screens/profile/ChatProfileViewModel.kt | 1 - .../com/flipcash/app/tipping/internal/screens/TipCardScreen.kt | 3 --- .../app/session/internal/SessionControllerGiftCardErrorTest.kt | 1 - 6 files changed, 9 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 47b1ce242..fd66432a9 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -35,7 +35,6 @@ import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.directsend.SendFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen -import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 1f5f32b66..bbe8e16dd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -17,7 +17,6 @@ import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.flipcash.shared.chat.models.ChatAction -import com.flipcash.shared.chat.models.ChatActionHandler import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.hazeSource diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index d6c7afe63..3f3e2a0e9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index aa5c7b138..07fe6acf6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -8,7 +8,6 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.controllers.BlocklistController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt index bd0107b8b..a56ef1671 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt @@ -1,10 +1,8 @@ package com.flipcash.app.tipping.internal.screens import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -25,7 +23,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha -import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.tipping.internal.TipFlowViewModel diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index b6888f13d..4b113c3cf 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -8,7 +8,6 @@ import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.internal.delegates.BillPresentationDelegate import com.flipcash.app.session.internal.delegates.CashLinkDelegate import com.flipcash.app.session.internal.delegates.CodeScanDelegate -import com.flipcash.app.session.internal.delegates.DepositDelegate import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController From 35e5d98496ef6fc9ae65c6777871aa467df30667 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 13:50:45 -0400 Subject: [PATCH 12/14] chore: back out unrelated fixes for separate PRs These slipped into the chat-profile/blocklist branch and are being PR'd on their own: - TipCardScreen: share-button/tip-card layout (reverted to code/cash) - ChatBottomBar: dismiss keyboard on the $ send button (removed the wrap) - ChatFlowScreen: dismiss keyboard before navigating away (removed the wrap) - ContactInfoContainer: restore the Indicator spacer + null preview Pre-existing keyboard.hideIfVisible wraps and the ViewProfile wrap are kept. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 24 +++++------- .../screens/components/ChatBottomBar.kt | 6 +-- .../components/ContactInfoContainer.kt | 6 ++- .../tipping/internal/screens/TipCardScreen.kt | 37 +++++++++---------- 4 files changed, 34 insertions(+), 39 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 6bb80def9..6c7259c37 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -33,7 +33,6 @@ import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.navigation.results.navigateForResult import com.getcode.navigation.results.resultBackNavigator import com.getcode.navigation.scenes.LocalSheetNavigator -import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -83,7 +82,6 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(identifier)) } - val keyboard = rememberKeyboardController() var hasOpened by rememberSaveable { mutableStateOf(false) } LaunchedEffect(openKeyboard) { if (openKeyboard) { @@ -112,18 +110,16 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo viewModel.eventFlow .filterIsInstance() .collect { (route, asSheet) -> - keyboard.hideIfVisible { - if (asSheet) { - // Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the - // sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss) - // before opening the new one. - sheetNavigator?.openAsSheet(route) - } else { - // A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent - // chain to the outer app nav host, the same destination as the old - // outerNavigator.navigate(route). - navigator.navigate(route) - } + if (asSheet) { + // Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the + // sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss) + // before opening the new one. + sheetNavigator?.openAsSheet(route) + } else { + // A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent + // chain to the outer app nav host, the same destination as the old + // outerNavigator.navigate(route). + navigator.navigate(route) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 4e22a06a8..68cb4fb7a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -160,11 +160,7 @@ internal fun UserControlBottomBar( state = state, hazeState = hazeState, hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) - } - } + onClick = { dispatch(ChatViewModel.Event.OnSendCash) } ) if (canType) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 3f3e2a0e9..b188be45e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -147,7 +149,7 @@ private fun Indicator( onClick = onClick ) - else -> Unit + else -> Spacer(modifier = modifier.fillMaxWidth()) } } @@ -272,6 +274,8 @@ private fun Preview_AllStates() { ContactInfoContainer(participant = knownContact, modifier = cardWidth) ContactInfoContainer(participant = unknownContact, modifier = cardWidth) ContactInfoContainer(participant = tipUser, modifier = cardWidth) + // Null participant: loading / unknown fallback avatar, name empty, no pill. + ContactInfoContainer(participant = null, modifier = cardWidth) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt index a56ef1671..f50ce234f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt @@ -1,8 +1,10 @@ package com.flipcash.app.tipping.internal.screens import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -23,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha +import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.tipping.internal.TipFlowViewModel @@ -43,22 +46,12 @@ internal fun TipCardScreen() { CodeScaffold( topBar = { - Column( - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x10), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppBarWithTitle( - title = stringResource(R.string.title_myTipCard), - titleAlignment = Alignment.CenterHorizontally, - backButton = true, - onBackIconClicked = { flowNavigator.back() }, - ) - Text( - text = stringResource(R.string.subtitle_myTipCard), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - } + AppBarWithTitle( + title = stringResource(R.string.title_myTipCard), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + ) }, bottomBar = { Row( @@ -69,7 +62,6 @@ internal fun TipCardScreen() { verticalAlignment = Alignment.CenterVertically, ) { Column( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { @@ -106,8 +98,14 @@ internal fun TipCardScreen() { .fillMaxSize() .padding(padding), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center ) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), + text = stringResource(R.string.subtitle_myTipCard), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Spacer(Modifier.weight(1f)) state.tipCard?.let { CompositionLocalProvider( LocalTipCardBaseAlpha provides 0.36f @@ -116,11 +114,12 @@ internal fun TipCardScreen() { // Fixed, device-independent card width matching iOS's TipcardScreen (300pt); // the card derives its QR, corner radius and avatar from this width, so both // platforms render the tip card at the same proportions. - tipCardWidth = 270.dp, + tipCardWidth = 300.dp, scannable = it, ) } } + Spacer(Modifier.weight(1f)) } } } From 7b95bb5c8bd47eec1c8538eb781212993ac42a68 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 20:31:23 -0400 Subject: [PATCH 13/14] feat(blocklist): gate blocklist and profile viewing behind a beta flag Add a Blocklist beta feature flag. The My Account blocklist entry only shows when the flag is enabled, and opening a chat participant's profile (the entry point to blocking) is gated the same way: ChatViewModel observes the flag and exposes canViewProfile in its state, so the top-bar tap and contact-card chevron are suppressed when the flag is off. --- .../app/messenger/internal/ChatViewModel.kt | 16 +++++++++- .../internal/screens/MessengerScreen.kt | 5 +++- .../internal/screens/components/ChatTopBar.kt | 23 +++++++++----- .../screens/components/MessageList.kt | 13 +++++--- .../internal/myaccount/MyAccountMenuItems.kt | 5 +++- .../myaccount/MyAccountScreenViewModel.kt | 30 +++++++++++++------ .../flipcash/app/featureflags/FeatureFlag.kt | 12 +++++++- 7 files changed, 80 insertions(+), 24 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index b346cfef4..e3d777eca 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -146,7 +146,15 @@ internal class ChatViewModel @Inject constructor( // open would be missed by the bottom bar before it subscribes, whereas state is durable // until the input is actually composed and can consume it. val messageInputRequested: Boolean = false, - ) + // Whether the Blocklist beta flag is enabled. Backs canViewProfile; observed in init. + val blocklistEnabled: Boolean = false, + ) { + // Opening the participant's profile (the entry point to blocking) is only available for tip + // DMs, and only when the Blocklist beta flag is on. Derived so it stays correct regardless + // of whether the flag or the chat type resolves first. + val canViewProfile: Boolean + get() = blocklistEnabled && chatType == ChatType.TIP_DM + } sealed interface Event { data class OnChatOpened(val identifier: ChatIdentifier) : Event @@ -190,6 +198,7 @@ internal class ChatViewModel @Inject constructor( data class LimitsChanged(val limits: Limits?) : Event data class AdvanceReadPointer(val messageId: Long) : Event data class ChatDeactivated(val isReadOnly: Boolean) : Event + data class BlocklistEnabledChanged(val enabled: Boolean) : Event } @OptIn(ExperimentalCoroutinesApi::class) @@ -434,6 +443,10 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.ChatDeactivated(isReadOnly = it)) } .launchIn(viewModelScope) + featureFlags.observe(FeatureFlag.Blocklist) + .onEach { dispatchEvent(Event.BlocklistEnabledChanged(it)) } + .launchIn(viewModelScope) + // Advance read pointer when user scrolls to messages eventFlow .filterIsInstance() @@ -906,6 +919,7 @@ internal class ChatViewModel @Inject constructor( is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) } is Event.AdvanceReadPointer -> { state -> state } is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } + is Event.BlocklistEnabledChanged -> { state -> state.copy(blocklistEnabled = event.enabled) } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index bbe8e16dd..934bc0d39 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -62,6 +62,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } is ChatAction.ViewProfile -> { + // The triggers (top-bar tap, contact-card chevron) are only clickable when the + // Blocklist beta flag is on, so no gating is needed here. state.participant?.let { keyboard.hideIfVisible { navigator.push(ChatStep.Profile(it)) @@ -74,7 +76,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } ChatInputScaffold( - topBar = { ChatTopBar(navigator, state.participant, chatActionHandler) }, + topBar = { ChatTopBar(navigator, state, chatActionHandler) }, bottomBar = { UserControlBottomBar( state = state, @@ -94,6 +96,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { separatorConfig = state.separatorConfig, otherReadPointer = otherReadPointer, onAction = chatActionHandler, + canViewProfile = state.canViewProfile, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 282d0230d..2486b440c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.getcode.navigation.core.CodeNavigator @@ -33,7 +33,7 @@ import com.getcode.ui.core.unboundedClickable @Composable internal fun ChatTopBar( navigator: CodeNavigator, - participant: ChatParticipant?, + state: ChatViewModel.State, chatActionHandler: ChatActionHandler, ) { var titleHeight by remember { mutableStateOf(0.dp) } @@ -59,14 +59,23 @@ internal fun ChatTopBar( }, title = { Row( - modifier = Modifier.fillMaxWidth().unboundedClickable { - chatActionHandler(ChatAction.ViewProfile) - }, + // Profile open is gated behind the Blocklist beta flag. + modifier = Modifier + .fillMaxWidth() + .then( + if (state.canViewProfile) { + Modifier.unboundedClickable { + chatActionHandler(ChatAction.ViewProfile) + } + } else { + Modifier + } + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { ParticipantAvatar( - participant = participant, + participant = state.participant, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), @@ -74,7 +83,7 @@ internal fun ChatTopBar( Text( modifier = Modifier.weight(1f), - text = participant?.displayName.orEmpty(), + text = state.participant?.displayName.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 918e21bdb..a924ec89a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -65,6 +65,7 @@ internal fun MessageList( separatorConfig: SeparatorConfig, otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, + canViewProfile: Boolean, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -269,11 +270,15 @@ internal fun MessageList( ContactInfoContainer( participant = state.participant, modifier = Modifier - .padding(horizontal = CodeTheme.dimens.grid.x12), + .fillMaxWidth(0.63f), onRefreshContact = { onAction(ChatAction.RefreshContact) }, - onOpenProfile = { - onAction(ChatAction.ViewProfile) - } + // null hides the chevron and makes the card non-tappable when the + // Blocklist beta flag is off. + onOpenProfile = if (canViewProfile) { + { onAction(ChatAction.ViewProfile) } + } else { + null + }, ) } } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index 4d6c3ad96..c7e65d809 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.menu.FullMenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.core.R as CoreR @@ -23,7 +24,9 @@ internal data object AccessKey : FullMenuItem() override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnAccessKeyClicked } -internal data object Blocklist : FullMenuItem() { +internal data object Blocklist : FullMenuItem( + featureFlag = FeatureFlag.Blocklist, +) { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Block) override val name: String diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index d5815d7b4..4355c1cf5 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -2,6 +2,7 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.lifecycle.viewModelScope import com.flipcash.app.auth.AuthManager +import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.StaffMenuItem @@ -44,11 +45,17 @@ internal class MyAccountScreenViewModel @Inject constructor( ) { internal data class State( val isBetaEnabled: Boolean = false, - val items: List> = FullMenuList + // Default hides staff-only AND flag-gated items until the real flag state loads, so a + // beta-gated item (e.g. Blocklist) never flashes before its flag is resolved. + val items: List> = + FullMenuList.filterNot { it is StaffMenuItem || it.featureFlag != null } ) internal sealed interface Event { - data class OnBetaFeaturesUnlocked(val unlocked: Boolean) : Event + data class OnBetaFeaturesUnlocked( + val unlocked: Boolean, + val flags: List = emptyList(), + ) : Event data object OnAccessKeyClicked : Event data object OnBlocklistClicked: Event data object OnViewAccessKey : Event @@ -64,11 +71,10 @@ internal class MyAccountScreenViewModel @Inject constructor( init { combine( featureFlagController.observeOverride(), - userManager.state.map { it.flags?.isStaff == true } - ) { override, isStaff -> - override || isStaff - }.map { - dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) + userManager.state.map { it.flags?.isStaff == true }, + featureFlagController.observe(), + ) { override, isStaff, flags -> + dispatchEvent(Event.OnBetaFeaturesUnlocked(override || isStaff, flags)) }.launchIn(viewModelScope) eventFlow @@ -156,12 +162,18 @@ internal class MyAccountScreenViewModel @Inject constructor( internal companion object { private fun buildItemList( isBetaEnabled: Boolean, + flags: List = emptyList(), ): List> { - return if (isBetaEnabled) { + val base = if (isBetaEnabled) { FullMenuList } else { FullMenuList.filterNot { item -> item is StaffMenuItem } } + // Flag-gated items (e.g. Blocklist) only show when their feature flag is enabled. + return base.filter { item -> + val flag = item.featureFlag ?: return@filter true + flags.find { it.flag.key == flag.key }?.enabled == true + } } val updateStateForEvent: (Event) -> ((State) -> State) = { event -> @@ -180,7 +192,7 @@ internal class MyAccountScreenViewModel @Inject constructor( is Event.OnBetaFeaturesUnlocked -> { state -> state.copy( isBetaEnabled = event.unlocked, - items = buildItemList(event.unlocked) + items = buildItemList(event.unlocked, event.flags) ) } } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 2bca1bf91..b85ebd2d1 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -274,7 +274,15 @@ sealed interface FeatureFlag { override val launched: Boolean = false override val visible: Boolean = true override val persistLogOut: Boolean = false - override val minTrack: FeatureTrack = FeatureTrack.Beta + } + + @FeatureFlagMarker + data object Blocklist: FeatureFlag { + override val key: String = "blocklist_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false } companion object { @@ -316,6 +324,7 @@ val FeatureFlag<*>.title: String FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.Tipping -> "Tipping" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" + FeatureFlag.Blocklist -> "Blocklist" } val FeatureFlag<*>.message: String @@ -346,6 +355,7 @@ val FeatureFlag<*>.message: String FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.Tipping -> "When enabled, you'll gain the ability to tip other users and set up your own tip card to receive tips" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" + FeatureFlag.Blocklist -> "When enabled, you'll gain the ability to open a chat participant's profile, block them, and manage your blocklist from My Account" } From b64d8b4a7a213578c75312dae52205a9fa9cf46a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 30 Jul 2026 20:31:24 -0400 Subject: [PATCH 14/14] feat(blocklist): surface errors when block or unblock fails Show an error alert when blocking a user from their chat profile or unblocking from the blocklist fails, so failures aren't silent. Also switch the unblock confirmation prompt to showMessage. --- apps/flipcash/core/src/main/res/values/strings.xml | 6 ++++++ .../internal/screens/profile/ChatProfileViewModel.kt | 4 ++++ .../myaccount/internal/blocklist/BlocklistViewModel.kt | 10 ++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0065069ce..aee91a6fa 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -916,4 +916,10 @@ Block people from sending you messages by tapping their profile and selecting block Joined %1$s + Something Went Wrong + We were unable to block the user. Please try again + Something Went Wrong + We were unable to unblock the user. Please try again + + \ No newline at end of file diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 07fe6acf6..6377c1d3c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -119,6 +119,10 @@ internal class ChatProfileViewModel @Inject constructor( } .onFailure { dispatchEvent(Event.BlockProcessing()) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToBlock), + message = resources.getString(R.string.error_description_failedToBlock), + ) } } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt index 83e6698a4..93b2580f8 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt @@ -57,7 +57,7 @@ internal class BlocklistViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { event -> - BottomBarManager.showAlert( + BottomBarManager.showMessage( title = resources.getString( R.string.prompt_title_unblockUser, event.user.displayName, @@ -81,7 +81,13 @@ internal class BlocklistViewModel @Inject constructor( coordinator.unblock(event.user.userId) // On success the row leaves the paged list; on failure the spinner reverts. .onSuccess { dispatchEvent(Event.UnblockProcessing(key, success = true)) } - .onFailure { dispatchEvent(Event.UnblockProcessing(key, error = true)) } + .onFailure { + dispatchEvent(Event.UnblockProcessing(key, error = true)) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToUnblock), + message = resources.getString(R.string.error_description_failedToUnblock), + ) + } } .launchIn(viewModelScope) }