From e4022836e4fcfd221866550b8d1b52d3caf127af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:07:13 -0400 Subject: [PATCH 01/39] feat(db): add chat_archive table via auto-migration 40 to 41 --- .../41.json | 960 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 9 +- .../app/persistence/dao/ChatArchiveDao.kt | 31 + .../persistence/entities/ChatArchiveEntity.kt | 29 + .../app/persistence/dao/ChatArchiveDaoTest.kt | 78 ++ 5 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json new file mode 100644 index 0000000000..c95a41202c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json @@ -0,0 +1,960 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "84c3e15ca2c7f659186651cf26a0a0eb", + "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', `textSubstitutions` TEXT, 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'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "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, `market_cap_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": "marketCapMetricsJson", + "columnName": "market_cap_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, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` 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" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "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, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` 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" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `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": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "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, '84c3e15ca2c7f659186651cf26a0a0eb')" + ] + } +} \ 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 dc60110d2b..ce48e94ad3 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow 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.ChatArchiveDao import com.flipcash.app.persistence.dao.ChatDraftDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao @@ -30,6 +31,7 @@ import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao import com.flipcash.app.persistence.dao.UserProfileDao import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.ChatArchiveEntity import com.flipcash.app.persistence.entities.ChatDraftEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity @@ -61,6 +63,7 @@ import com.getcode.utils.subByteArray ChatMessageEntity::class, ChatMemberEntity::class, ChatDraftEntity::class, + ChatArchiveEntity::class, BlockedUserEntity::class, UserProfileEntity::class, LinkPreviewEntity::class, @@ -111,8 +114,11 @@ import com.getcode.utils.subByteArray AutoMigration(from = 37, to = 38), // chat_metadata.creator_hex (nullable), use_e2ee (default 0) AutoMigration(from = 38, to = 39), // link_previews table AutoMigration(from = 39, to = 40, spec = FlipcashDatabase.Migration39To40::class), + // The chat_archive table. An AutoMigration rather than the fallbackToDestructiveMigration() + // below, for the same reason as chat_draft: an archive set cannot be re-fetched. + AutoMigration(from = 40, to = 41), // chat_archive table ], - version = 40, + version = 41, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -125,6 +131,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMessageDao(): ChatMessageDao abstract fun chatMemberDao(): ChatMemberDao abstract fun chatDraftDao(): ChatDraftDao + abstract fun chatArchiveDao(): ChatArchiveDao abstract fun blockedUserDao(): BlockedUserDao abstract fun userProfileDao(): UserProfileDao abstract fun linkPreviewDao(): LinkPreviewDao diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt new file mode 100644 index 0000000000..0fffcbe1e2 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatArchiveDao { + + /** IGNORE, not REPLACE: archiving an archived chat is a no-op and keeps its timestamp. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(archive: ChatArchiveEntity) + + @Query("SELECT chat_id_hex FROM chat_archive") + fun observeIds(): Flow> + + @Query("SELECT chat_id_hex FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun find(chatIdHex: String): String? + + @Query("SELECT archived_at FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun archivedAt(chatIdHex: String): Long? + + @Query("DELETE FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun delete(chatIdHex: String) + + @Query("DELETE FROM chat_archive") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt new file mode 100644 index 0000000000..7f94bc180b --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A chat the viewer has archived. Presence of the row is the whole state. + * + * Its own table rather than a `chat_metadata` column, on purpose: `chat_metadata.is_hidden` is + * server-owned and `ChatMetadataDao.updateServerOwnedFields` rewrites it on every feed sync, so a + * client-owned flag stored there would be reset by the next sync. A row here is never touched by + * the metadata round-trip. + * + * A row is only ever written by the user's own archive action and removed by their unarchive, by + * leaving the group, or by account erasure; no incoming message, mention or payment clears it. + * + * Added by an `AutoMigration` rather than left to `fallbackToDestructiveMigration()`: an archive + * set cannot be re-fetched until the server stores it, so a version bump that dropped the file + * would lose it. + */ +@Entity(tableName = "chat_archive") +data class ChatArchiveEntity( + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "archived_at") + val archivedAt: Long, +) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt new file mode 100644 index 0000000000..a556c6fb07 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class ChatArchiveDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ChatArchiveDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + FlipcashDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.chatArchiveDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `insert then find returns the id`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 1_000L)) + assertEquals("aa", dao.find("aa")) + } + + @Test + fun `find is null for a chat that was never archived`() = runTest { + assertNull(dao.find("zz")) + } + + @Test + fun `archiving twice keeps the first timestamp`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 9_000L)) + assertEquals(1_000L, dao.archivedAt("aa")) + } + + @Test + fun `delete removes only the named chat`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("bb", 1_000L)) + dao.delete("bb") + assertEquals("aa", dao.find("aa")) + assertNull(dao.find("bb")) + } + + @Test + fun `deleteAll empties the table`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("bb", 1_000L)) + dao.deleteAll() + assertEquals(emptyList(), dao.observeIds().first()) + } + + @Test + fun `observeIds emits the current set and re-emits on change`() = runTest { + assertEquals(emptyList(), dao.observeIds().first()) + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + assertEquals(listOf("aa"), dao.observeIds().first()) + } +} From 41e26f8dab0729699c64eaf617c9c8870a8cef9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:08:48 -0400 Subject: [PATCH 02/39] feat(db): add ChatArchiveDataSource The sources test module has no Robolectric, so the test covers only the no-database paths. The read/write paths are exercised over a real database by the archive store's test. --- .../sources/ChatArchiveDataSource.kt | 43 +++++++++++++++++++ .../sources/ChatArchiveDataSourceTest.kt | 30 +++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt new file mode 100644 index 0000000000..b8d39c61f6 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `chat_archive` table, keyed by chat id hex. + * + * No database means nothing archived rather than an error, like the other data sources here. One + * difference from [ChatMetadataDataSource.observeAll], which returns `emptyFlow()` in that case: + * [observeIds] returns a flow of an empty set. It is combined with the metadata and member flows + * in the feed, and `combine` emits nothing until every input has emitted once, so an empty flow + * would hold the whole feed back. + */ +@Singleton +class ChatArchiveDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeIds(): Flow> = + db?.chatArchiveDao()?.observeIds()?.map { it.toSet() } ?: flowOf(emptySet()) + + suspend fun isArchived(chatIdHex: String): Boolean = + db?.chatArchiveDao()?.find(chatIdHex) != null + + suspend fun archive(chatIdHex: String, now: Long) { + db?.chatArchiveDao()?.insertIfAbsent(ChatArchiveEntity(chatIdHex, archivedAt = now)) + } + + suspend fun unarchive(chatIdHex: String) { + db?.chatArchiveDao()?.delete(chatIdHex) + } + + suspend fun clear() { + db?.chatArchiveDao()?.deleteAll() + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt new file mode 100644 index 0000000000..2a6e9a1e98 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.persistence.sources + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * No Robolectric here, so only the no-database paths run; the read/write paths are covered over a + * real database by the archive store's test. + */ +class ChatArchiveDataSourceTest { + + private val source = ChatArchiveDataSource() + + @Test + fun `with no database observeIds is an empty set, not an empty flow`() = runTest { + // An empty *flow* would stall a combine() that includes it; an empty *set* lets it emit. + assertEquals(emptySet(), source.observeIds().first()) + } + + @Test + fun `with no database nothing is archived and writes are no-ops`() = runTest { + source.archive("aa", now = 1L) + source.unarchive("aa") + source.clear() + assertFalse(source.isArchived("aa")) + } +} From 03c26fea3a3313cd785c9b22058ea0fb0583d83b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:11:20 -0400 Subject: [PATCH 03/39] feat(chat): add archive list projection and notify rule with fixture test --- .../flipcash/shared/chat/ArchiveNotifyRule.kt | 31 + .../shared/chat/ChatListProjection.kt | 96 +++ .../shared/chat/ChatArchiveVectorTest.kt | 123 ++++ .../chat/src/test/resources/chat_archive.json | 671 ++++++++++++++++++ 4 files changed, 921 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/resources/chat_archive.json diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt new file mode 100644 index 0000000000..ddd5834cc1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt @@ -0,0 +1,31 @@ +package com.flipcash.shared.chat + +/** + * Whether a push for a chat may post a notification. + * + * - **Muted: never**, archived or not. Mute already suppresses every push and archiving must not + * add a way through it (rule 2). + * - **Not archived and not muted: always.** Archive adds a filter; it never changes what an + * ordinary chat does. + * - **Archived, not muted: only a message addressed to the viewer**, which is an @mention of them + * or a reply to one of their messages (rule 3). The message's kind does not matter: received + * cash, media and system messages do not break through on their own, because in a tip DM most + * messages may be cash and letting them through would make archive useless where people most + * want quiet. + * + * [mentionsViewer] and [repliesToViewer] are false when the device cannot tell: the message text + * is not available, a mention names a handle that is not the viewer's, or the replied-to message + * is not stored locally. Both platforms default the same way. + * + * Pure: no Android types. The fixture's `notify` cases drive this directly. + */ +fun shouldNotify( + archived: Boolean, + muted: Boolean, + mentionsViewer: Boolean, + repliesToViewer: Boolean, +): Boolean = when { + muted -> false + !archived -> true + else -> mentionsViewer || repliesToViewer +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt new file mode 100644 index 0000000000..999b32c76b --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt @@ -0,0 +1,96 @@ +package com.flipcash.shared.chat + +/** The chip selected above the Chats list. Not persisted: every cold start opens on [All]. */ +enum class ChatListFilter { All, Unread, Groups } + +/** + * One chat as the list projection sees it. [id] is opaque here (the app passes the chat id hex; + * the fixture passes its own ids). [unread] is the chat's unread count, with null meaning "unread + * by an unknown count", which counts as unread. + */ +data class ChatListEntry( + val id: String, + val isGroup: Boolean, + val lastActivityMs: Long, + val archived: Boolean, + val muted: Boolean, + val hidden: Boolean, + val unread: Int?, +) { + val isUnread: Boolean get() = unread != 0 +} + +/** + * What the Chats list shows, derived from every chat the viewer has. + * + * Every id list is ordered by last activity, newest first. Hidden chats (blocked) appear nowhere, + * including [archived]; their archive record stays in storage in case they are unblocked. + * + * @property main every chat that is neither archived nor hidden: the "All" list. + * @property unreadChip [main] filtered to unread chats, unknown counts included. + * @property groupsChip [main] filtered to groups. The chip's *number* is [groupsChipCount], the + * unread groups among them. + * @property archived the chats under the Archived row, muted ones included. + * @property archivedRowVisible whether any chat is archived; the row is shown whenever it is, even + * when [main] is empty. + * @property archivedRowCount unread archived chats that are not muted. Muted archived chats count + * toward nothing, as muted chats count toward nothing there today. + * @property tabBadge unread chats in [main], muted ones included, as the Chats tab badge counts + * today. + */ +data class ChatListProjection( + val main: List, + val unreadChip: List, + val groupsChip: List, + val archived: List, + val unreadChipCount: Int, + val groupsChipCount: Int, + val archivedRowVisible: Boolean, + val archivedRowCount: Int, + val tabBadge: Int, +) { + fun idsFor(filter: ChatListFilter): List = when (filter) { + ChatListFilter.All -> main + ChatListFilter.Unread -> unreadChip + ChatListFilter.Groups -> groupsChip + } + + companion object { + val Empty = ChatListProjection( + main = emptyList(), + unreadChip = emptyList(), + groupsChip = emptyList(), + archived = emptyList(), + unreadChipCount = 0, + groupsChipCount = 0, + archivedRowVisible = false, + archivedRowCount = 0, + tabBadge = 0, + ) + } +} + +/** + * Rule 1 and the numbers the list draws. Pure, so the fixture's `list` cases drive it directly. + * + * Ties in last activity keep their input order (`sortedByDescending` is stable). + */ +fun projectChatList(entries: List): ChatListProjection { + val (archived, active) = entries.filterNot { it.hidden }.partition { it.archived } + val main = active.sortedByDescending { it.lastActivityMs } + val archivedSorted = archived.sortedByDescending { it.lastActivityMs } + val unread = main.filter { it.isUnread } + val groups = main.filter { it.isGroup } + + return ChatListProjection( + main = main.map { it.id }, + unreadChip = unread.map { it.id }, + groupsChip = groups.map { it.id }, + archived = archivedSorted.map { it.id }, + unreadChipCount = unread.size, + groupsChipCount = groups.count { it.isUnread }, + archivedRowVisible = archivedSorted.isNotEmpty(), + archivedRowCount = archivedSorted.count { it.isUnread && !it.muted }, + tabBadge = unread.size, + ) +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt new file mode 100644 index 0000000000..5d9c394e8a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt @@ -0,0 +1,123 @@ +package com.flipcash.shared.chat + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `test-vectors/chat_archive.json`. The canonical copy lives in the orchestrator repo; this one is + * synced. A failure here is a real regression or a cross-platform decision that has to be made in + * the canonical fixture and re-synced to both platforms, never a local edit. + * + * Mention and reply detection stay native, so the fixture hands them in as inputs: these tests pin + * the rules, not the parsers. `"unknown"` means the device could not tell, which both rules treat + * as false. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatArchiveVectorTest { + + @Test + fun `notify decisions match the cross-platform vectors`() { + val cases = fixture().getJSONArray("notify").objects() + // A fixture that failed to load reads as an empty list, and a loop over one passes. + assertTrue(cases.isNotEmpty(), "chat_archive.json loaded no notify vectors") + + for (case in cases) { + val push = case.getJSONObject("push") + val expect = case.getJSONObject("expect") + val name = case.getString("name") + + val actual = shouldNotify( + archived = push.getBoolean("archived"), + muted = push.getBoolean("muted"), + mentionsViewer = push.triState("mentionsViewer"), + repliesToViewer = push.triState("repliesToViewer"), + ) + assertEquals(expect.getBoolean("notify"), actual, "$name: ${case.optString("note")}") + // Rule 3: nothing a message does unarchives the chat. No Android code path can write + // archive from a push, so the fixture's `expect.archived` is checked against the input. + assertEquals(push.getBoolean("archived"), expect.getBoolean("archived"), "$name: a push never changes archive") + } + } + + @Test + fun `list projections match the cross-platform vectors`() { + val cases = fixture().getJSONArray("list").objects() + assertTrue(cases.isNotEmpty(), "chat_archive.json loaded no list vectors") + + for (case in cases) { + val name = case.getString("name") + val note = case.optString("note") + val expect = case.getJSONObject("expect") + val entries = case.getJSONArray("chats").objects().map { chat -> + ChatListEntry( + id = chat.getString("id"), + isGroup = chat.getString("type") == "group", + lastActivityMs = chat.getLong("lastActivity"), + archived = chat.getBoolean("archived"), + muted = chat.getBoolean("muted"), + hidden = chat.getBoolean("hidden"), + unread = chat.unread(), + ) + } + + val actual = projectChatList(entries) + + assertEquals(expect.strings("main"), actual.main, "$name: main. $note") + assertEquals(expect.strings("unreadChip"), actual.unreadChip, "$name: unreadChip. $note") + assertEquals(expect.strings("groupsChip"), actual.groupsChip, "$name: groupsChip. $note") + assertEquals(expect.strings("archived"), actual.archived, "$name: archived. $note") + val counts = expect.getJSONObject("chipCounts") + assertEquals(counts.getInt("unread"), actual.unreadChipCount, "$name: unread chip count. $note") + assertEquals(counts.getInt("groups"), actual.groupsChipCount, "$name: groups chip count. $note") + val row = expect.getJSONObject("archivedRow") + assertEquals(row.getBoolean("visible"), actual.archivedRowVisible, "$name: archived row visible. $note") + assertEquals(row.getInt("count"), actual.archivedRowCount, "$name: archived row count. $note") + assertEquals(expect.getInt("tabBadge"), actual.tabBadge, "$name: tab badge. $note") + } + } + + @Test + fun `a filter selects the ids its chip lists`() { + val projection = projectChatList( + listOf( + ChatListEntry("a", isGroup = false, lastActivityMs = 3, archived = false, muted = false, hidden = false, unread = 0), + ChatListEntry("b", isGroup = true, lastActivityMs = 2, archived = false, muted = false, hidden = false, unread = 4), + ) + ) + assertEquals(listOf("a", "b"), projection.idsFor(ChatListFilter.All)) + assertEquals(listOf("b"), projection.idsFor(ChatListFilter.Unread)) + assertEquals(listOf("b"), projection.idsFor(ChatListFilter.Groups)) + } + + private fun JSONObject.triState(key: String): Boolean { + val value = get(key) + // true / false / "unknown": anything else is a typo in the fixture, not a case to guess at. + assertTrue(value is Boolean || value == "unknown", "$key must be true, false or \"unknown\", was $value") + return value == true + } + + private fun JSONObject.unread(): Int? { + val value = get("unread") + assertTrue(value is Int || value == "unknown", "unread must be an int or \"unknown\", was $value") + return value as? Int + } + + private fun JSONObject.strings(key: String): List = + getJSONArray(key).let { array -> (0 until array.length()).map { array.getString(it) } } + + private fun JSONArray.objects(): List = (0 until length()).map { getJSONObject(it) } + + private fun fixture(): JSONObject { + val json = javaClass.classLoader!! + .getResourceAsStream("chat_archive.json")!! + .bufferedReader().use { it.readText() } + return JSONObject(json) + } +} diff --git a/apps/flipcash/shared/chat/src/test/resources/chat_archive.json b/apps/flipcash/shared/chat/src/test/resources/chat_archive.json new file mode 100644 index 0000000000..5f18c077f0 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/resources/chat_archive.json @@ -0,0 +1,671 @@ +{ + "notify": [ + { + "name": "plain-notifies", + "push": { + "archived": false, + "muted": false, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": false + }, + "note": "Not archived, not muted: today's behavior, unchanged." + }, + { + "name": "plain-muted-silent", + "push": { + "archived": false, + "muted": true, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": false + }, + "note": "Not archived, muted: today's behavior, unchanged." + }, + { + "name": "plain-muted-mention-silent", + "push": { + "archived": false, + "muted": true, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": false + }, + "note": "Mute already ignores mentions. Archive must not be the thing that changes that." + }, + { + "name": "archived-text-silent", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Ordinary traffic in an archived chat is stored silently." + }, + { + "name": "archived-cash-silent", + "push": { + "archived": true, + "muted": false, + "kind": "cash", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Cash does not break through on its own: in a tip DM most messages may be cash, which would make archive useless there." + }, + { + "name": "archived-media-silent", + "push": { + "archived": true, + "muted": false, + "kind": "media", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3." + }, + { + "name": "archived-system-silent", + "push": { + "archived": true, + "muted": false, + "kind": "system", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Joins, renames and the like never break through." + }, + { + "name": "archived-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. An @mention of the viewer is addressed to them." + }, + { + "name": "archived-media-caption-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "media", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. A mention counts wherever the text is, including a caption." + }, + { + "name": "archived-reply-to-viewer-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": true + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. A reply to one of the viewer's messages is addressed to them." + }, + { + "name": "archived-reply-to-someone-else-silent", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. A reply is not addressed to you just because it is a reply." + }, + { + "name": "archived-reply-unknown-target-silent", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": "unknown" + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. The replied-to message is not stored on this device. Unknown counts as not addressed, on both platforms." + }, + { + "name": "archived-mention-unresolved-silent", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": "unknown", + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. A mention that does not resolve to a user counts as not addressed." + }, + { + "name": "archived-reply-and-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. Either condition is enough." + }, + { + "name": "archived-muted-text-silent", + "push": { + "archived": true, + "muted": true, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + }, + { + "name": "archived-muted-cash-silent", + "push": { + "archived": true, + "muted": true, + "kind": "cash", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + }, + { + "name": "archived-muted-mention-silent", + "push": { + "archived": true, + "muted": true, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2. The case most likely to be got wrong: a mention breaks through archive but never through mute." + }, + { + "name": "archived-muted-reply-to-viewer-silent", + "push": { + "archived": true, + "muted": true, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": true + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + } + ], + "list": [ + { + "name": "nothing-archived", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": false, + "muted": false, + "hidden": false, + "unread": 2 + }, + { + "id": "b", + "type": "group", + "lastActivity": 2, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "c", + "type": "group", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [ + "a", + "b", + "c" + ], + "unreadChip": [ + "a", + "c" + ], + "groupsChip": [ + "b", + "c" + ], + "archived": [], + "chipCounts": { + "unread": 2, + "groups": 1 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "No archive row when nothing is archived. Groups counts unread groups (c), not all groups." + }, + { + "name": "archived-leaves-main-chips-and-badge", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": false, + "muted": false, + "hidden": false, + "unread": 2 + }, + { + "id": "b", + "type": "group", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 4 + } + ], + "expect": { + "main": [ + "a" + ], + "unreadChip": [ + "a" + ], + "groupsChip": [], + "archived": [ + "b" + ], + "chipCounts": { + "unread": 1, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 1 + }, + "note": "Rule 1. The archived group is in no chip and not in the badge; it shows only in the Archived row's count." + }, + { + "name": "archived-muted-not-counted", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": true, + "muted": true, + "hidden": false, + "unread": 9 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "c", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "c" + ], + "unreadChip": [], + "groupsChip": [], + "archived": [ + "a", + "b" + ], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 0 + }, + "note": "The Archived row counts unread chats that are not muted. A muted archived chat stays listed under Archived but counts toward nothing." + }, + { + "name": "muted-in-main-still-in-badge", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": false, + "muted": true, + "hidden": false, + "unread": 1 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [ + "a", + "b" + ], + "unreadChip": [ + "a", + "b" + ], + "groupsChip": [], + "archived": [], + "chipCounts": { + "unread": 2, + "groups": 0 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "Archive does not change how mute affects the main list: both apps count muted unread chats in the badge today, and still do." + }, + { + "name": "unknown-unread-counts-as-unread", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": false, + "muted": false, + "hidden": false, + "unread": "unknown" + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": true, + "muted": false, + "hidden": false, + "unread": "unknown" + } + ], + "expect": { + "main": [ + "a" + ], + "unreadChip": [ + "a" + ], + "groupsChip": [], + "archived": [ + "b" + ], + "chipCounts": { + "unread": 1, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 1 + }, + "note": "Android shows a dot when the count is not known yet. It is unread for every count here." + }, + { + "name": "hidden-and-archived-appears-nowhere", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": true, + "unread": 3 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "b" + ], + "unreadChip": [], + "groupsChip": [], + "archived": [], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 0 + }, + "note": "Rule 5. Hidden wins. The only archived chat is hidden, so the Archived row is not shown at all." + }, + { + "name": "everything-archived", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "b", + "type": "group", + "lastActivity": 1, + "archived": true, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [], + "unreadChip": [], + "groupsChip": [], + "archived": [ + "a", + "b" + ], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 0 + }, + "note": "The main list is empty but the Archived row must still render. On iOS that means the empty state cannot replace the List." + }, + { + "name": "order-is-last-activity-everywhere", + "chats": [ + { + "id": "old", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "new", + "type": "tipDm", + "lastActivity": 5, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "arch-old", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "arch-new", + "type": "tipDm", + "lastActivity": 4, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "new", + "old" + ], + "unreadChip": [ + "new", + "old" + ], + "groupsChip": [], + "archived": [ + "arch-new", + "arch-old" + ], + "chipCounts": { + "unread": 2, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "Every list sorts by last activity, newest first, Archived included." + } + ] +} From 78a250f6e22732831843196ef9e3b05e619d4045 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:15:35 -0400 Subject: [PATCH 04/39] feat(chat): add ChatArchiveStore with a Room-backed implementation --- .../flipcash/shared/chat/ChatArchiveStore.kt | 44 +++++++++++ .../flipcash/shared/chat/inject/ChatModule.kt | 8 ++ .../chat/internal/RealChatArchiveStore.kt | 36 +++++++++ .../shared/chat/RealChatArchiveStoreTest.kt | 77 +++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt new file mode 100644 index 0000000000..928e66d3a7 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt @@ -0,0 +1,44 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * The chats the viewer has archived, kept on this device. + * + * Archive changes only when the user changes it: nothing here is called from an incoming message, + * mention, payment or from opening the chat. The two writers outside the UI are leaving a group + * and account erasure. + * + * Nothing reaches the network; the set is local until the server stores it, which is why it lives + * in its own Room table. + */ +interface ChatArchiveStore { + + /** The archived chats, re-emitted on every change. Emits an empty set before sign-in. */ + fun observeArchived(): Flow> + + suspend fun isArchived(chatId: ChatId): Boolean + + /** Archives [chatId]. Archiving an archived chat does nothing. */ + suspend fun archive(chatId: ChatId) + + /** Un-archives [chatId]; also what leaving a group does. */ + suspend fun unarchive(chatId: ChatId) + + /** Drops every record. Account erasure. */ + suspend fun clearAll() + + /** + * Archives nothing and ignores writes. The default for delegates that take a store, so the + * dozens of tests that construct them directly keep compiling without a fake. + */ + object None : ChatArchiveStore { + override fun observeArchived(): Flow> = flowOf(emptySet()) + override suspend fun isArchived(chatId: ChatId): Boolean = false + override suspend fun archive(chatId: ChatId) = Unit + override suspend fun unarchive(chatId: ChatId) = Unit + override suspend fun clearAll() = Unit + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 7629e5be2c..c3eddf96fe 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.inject +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.RosterSearchSource @@ -8,6 +9,7 @@ import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool import com.flipcash.shared.chat.internal.ServerRosterSearchSource import com.flipcash.shared.chat.internal.OutgoingEncryption +import com.flipcash.shared.chat.internal.RealChatArchiveStore import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.getcode.opencode.providers.SessionListener @@ -34,6 +36,12 @@ abstract class ChatModule { impl: RealChatDraftStore ): ChatDraftStore + @Binds + @Singleton + abstract fun bindChatArchiveStore( + impl: RealChatArchiveStore + ): ChatArchiveStore + @Binds internal abstract fun bindOutgoingEncryption( impl: DmOutgoingEncryption diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt new file mode 100644 index 0000000000..2a81e0f37a --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatArchiveDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatArchiveStore +import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** [ChatArchiveStore] over the `chat_archive` table. */ +@Singleton +class RealChatArchiveStore internal constructor( + private val dataSource: ChatArchiveDataSource, + private val now: () -> Long, +) : ChatArchiveStore { + + @Inject + constructor(dataSource: ChatArchiveDataSource) : this(dataSource, System::currentTimeMillis) + + override fun observeArchived(): Flow> = + dataSource.observeIds().map { hexes -> hexes.mapTo(HashSet()) { ChatId(it) } } + + override suspend fun isArchived(chatId: ChatId): Boolean = dataSource.isArchived(chatId.hex) + + override suspend fun archive(chatId: ChatId) = dataSource.archive(chatId.hex, now()) + + override suspend fun unarchive(chatId: ChatId) = dataSource.unarchive(chatId.hex) + + override suspend fun clearAll() = dataSource.clear() + + // Matches ChatEntityMapper.chatIdHex, so the record is keyed the same way as the chat's rows. + private val ChatId.hex: String + get() = bytes.toList().hexEncodedString() +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt new file mode 100644 index 0000000000..819673697c --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.shared.chat + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.ChatArchiveDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.internal.RealChatArchiveStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class RealChatArchiveStoreTest { + + private val a = ChatId("aabbccdd") + private val b = ChatId("11223344") + private val store: ChatArchiveStore = RealChatArchiveStore(ChatArchiveDataSource()) { 1_000L } + + @Before + fun setUp() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "chat-archive-store-test") + runBlocking { store.clearAll() } + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + @Test + fun `archive marks the chat archived and observeArchived reports it`() = runBlocking { + store.archive(a) + assertTrue(store.isArchived(a)) + assertFalse(store.isArchived(b)) + assertEquals(setOf(a), store.observeArchived().first()) + } + + @Test + fun `archiving twice keeps one record`() = runBlocking { + store.archive(a) + store.archive(a) + assertEquals(setOf(a), store.observeArchived().first()) + } + + @Test + fun `unarchive clears only that chat`() = runBlocking { + store.archive(a) + store.archive(b) + store.unarchive(a) + assertFalse(store.isArchived(a)) + assertTrue(store.isArchived(b)) + assertEquals(setOf(b), store.observeArchived().first()) + } + + @Test + fun `clearAll empties the set`() = runBlocking { + store.archive(a) + store.archive(b) + store.clearAll() + assertFalse(store.isArchived(a)) + assertEquals(emptySet(), store.observeArchived().first()) + } + + @Test + fun `None archives nothing`() = runBlocking { + assertEquals(emptySet(), ChatArchiveStore.None.observeArchived().first()) + assertFalse(ChatArchiveStore.None.isArchived(a)) + } +} From f342655d9e5461c02a45f46ea05c8e9842868764 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:19:51 -0400 Subject: [PATCH 05/39] feat(chat): filter archived chats out of the feed and add archivedFeed --- .../flipcash/shared/chat/ChatCoordinator.kt | 9 + .../com/flipcash/shared/chat/ChatList.kt | 6 + .../com/flipcash/shared/chat/ChatState.kt | 3 + .../internal/delegates/FeedSyncDelegate.kt | 29 ++- .../shared/chat/ChatArchiveFeedTest.kt | 167 ++++++++++++++++++ 5 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt 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 a07750b4ff..1020f588db 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 @@ -52,6 +52,15 @@ interface FeedOperations { /** Emits the number of conversations of any of [chatTypes] that have unread messages. */ fun observeUnreadConversations(vararg chatTypes: ChatType): Flow + /** + * [feed]'s counterpart: the same rows for the chats the viewer archived, newest first. Emits + * nothing until the list is known, like [feed]. + */ + fun archivedFeed(vararg chatTypes: ChatType): Flow> + + /** [currentFeed]'s counterpart for archived chats. */ + fun currentArchivedFeed(vararg chatTypes: ChatType): List? + /** * The same conversations as [feed], paged. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt index 3402837b98..cec1986fca 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -19,3 +19,9 @@ fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chat /** How many of the chats the Chats list shows have unread messages; the Chats tab badge. */ fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) + +/** [FeedOperations.archivedFeed] for the chats the Chats list shows. */ +fun FeedOperations.archivedChatListFeed(): Flow> = archivedFeed(*chatListTypes) + +/** [FeedOperations.currentArchivedFeed] for the chats the Chats list shows. */ +fun FeedOperations.currentArchivedChatListFeed(): List? = currentArchivedFeed(*chatListTypes) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 42ee2647e5..92b5f65ab3 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -23,6 +23,9 @@ data class ChatState( val feedSyncState: FeedSyncState = FeedSyncState.Idle, val historyHydration: ChatHydrationState = ChatHydrationState.Unknown, val activeChat: ChatId? = null, + // The chats the viewer archived, read in the same combine that builds [feed] so the feed and + // the archive set always change together. `feed` and `archivedFeed` split on it. + val archived: Set = emptySet(), ) data class ChatSummary( 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 7010fa0c6e..495026f60e 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 @@ -22,6 +22,7 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -77,6 +78,7 @@ class FeedSyncDelegate @Inject constructor( private val userManager: UserManager, private val messagingController: ChatMessagingController, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, + private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, ) : FeedOperations { companion object { @@ -129,13 +131,21 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() - return stateHolder.state.mapNotNull { state -> summaries(state, requested) } + return stateHolder.state.mapNotNull { state -> summaries(state, requested, archived = false) } } override fun currentFeed(vararg chatTypes: ChatType): List? = - summaries(stateHolder.state.value, chatTypes.toSet()) + summaries(stateHolder.state.value, chatTypes.toSet(), archived = false) - private fun summaries(state: ChatState, requested: Set): List? { + override fun archivedFeed(vararg chatTypes: ChatType): Flow> { + val requested = chatTypes.toSet() + return stateHolder.state.mapNotNull { state -> summaries(state, requested, archived = true) } + } + + override fun currentArchivedFeed(vararg chatTypes: ChatType): List? = + summaries(stateHolder.state.value, chatTypes.toSet(), archived = true) + + private fun summaries(state: ChatState, requested: Set, archived: Boolean): List? { // Nothing until the list is known: the chat list shows its empty state for an emitted empty // list, so it must not see one that only means "not read yet". Chats on disk are known as // soon as they are read. An empty database is not, because on a fresh sign-in it is empty @@ -149,6 +159,9 @@ class FeedSyncDelegate @Inject constructor( val selfPhone = userManager.profile?.verifiedPhoneNumber return feed .filter { it.type in requested } + // The one place archive is applied (rule 1): the list, every chip and the tab badge read + // `feed`, so none of them can disagree about what is archived. + .filter { (it.chatId in state.archived) == archived } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> val count = unreadCount(metadata, selfId) { id -> state.readStampAt(metadata.chatId, id) } @@ -226,10 +239,12 @@ class FeedSyncDelegate @Inject constructor( feedObserverJob = combine( metadataDataSource.observeAll(), memberDataSource.observeAll(), - ) { metadataEntities, membersByChat -> - buildFeedFromDb(metadataEntities, membersByChat) - }.onEach { (feed, readStamps) -> - stateHolder.update { it.copy(feed = feed, readStamps = readStamps) } + archiveStore.observeArchived(), + ) { metadataEntities, membersByChat, archived -> + buildFeedFromDb(metadataEntities, membersByChat) to archived + }.onEach { (built, archived) -> + val (feed, readStamps) = built + stateHolder.update { it.copy(feed = feed, readStamps = readStamps, archived = archived) } fetchMissingReadStamps(feed) }.launchIn(scope) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt new file mode 100644 index 0000000000..b863fb3014 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt @@ -0,0 +1,167 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * Rule 1: an archived chat leaves the list, every chip and the tab badge together, because they + * all read the one feed the delegate filters. It appears only in the archived feed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatArchiveFeedTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val tipHex = "aabbccdd" + private val groupHex = "11223344" + + private fun entity(hex: String, type: ChatType) = ChatMetadataEntity( + chatIdHex = hex, + chatType = type.name, + lastActivityEpochMs = 2_000, + lastMessageId = 2, + isMember = true, + ) + + private fun message(id: Long) = ChatMessage( + messageId = id, + senderId = otherId, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = id, + ) + + private val selfUnread = ChatMember( + userId = selfId, + userProfile = UserProfile.Empty, + pointers = listOf( + MessagePointer(PointerType.READ, selfId, value = 1, timestamp = Instant.fromEpochSeconds(1_000)) + ), + ) + + // A tip DM renders only when the other member is addressable, so give them a verified phone. + private val tipOther = ChatMember( + userId = otherId, + userProfile = UserProfile.Empty.copy( + displayName = "Ada", + phoneNumber = VerifiableContactMethod("+15551234567", verified = true), + ), + pointers = emptyList(), + ) + + private class Fixture(val delegate: FeedSyncDelegate, val archived: MutableStateFlow>) + + private fun TestScope.fixture(initiallyArchived: Set): Fixture { + val entities = listOf(entity(tipHex, ChatType.TIP_DM), entity(groupHex, ChatType.GROUP)) + val members = mapOf(tipHex to listOf(selfUnread, tipOther), groupHex to listOf(selfUnread)) + + val messageDataSource = mockk(relaxed = true).also { + coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } + coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() } + } + val metadataDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(entities) + every { source.toMetadata(any(), any(), any()) } answers { + val row = firstArg() + ChatMetadata( + chatId = ChatId(row.chatIdHex), + type = ChatType.valueOf(row.chatType), + members = secondArg(), + lastMessage = thirdArg(), + lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), + title = row.title, + rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion), + ) + } + } + val memberDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(members) + } + + val archived = MutableStateFlow(initiallyArchived) + val archiveStore = object : ChatArchiveStore by ChatArchiveStore.None { + override fun observeArchived() = archived + } + + val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), + chatController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = ChatStateHolder().apply { update { it.copy(feedSyncState = FeedSyncState.Synced) } }, + userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns null + }, + archiveStore = archiveStore, + ) + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + return Fixture(delegate, archived) + } + + private val listTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP) + + @Test + fun `an archived chat is not in the feed, the current feed or the badge`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + assertEquals(listOf(ChatId(tipHex)), f.delegate.feed(*listTypes).first().map { it.metadata.chatId }) + assertEquals(listOf(ChatId(tipHex)), f.delegate.currentFeed(*listTypes)!!.map { it.metadata.chatId }) + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + } + + @Test + fun `an archived chat is in the archived feed and nowhere else`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + assertEquals(listOf(ChatId(groupHex)), f.delegate.archivedFeed(*listTypes).first().map { it.metadata.chatId }) + assertEquals(listOf(ChatId(groupHex)), f.delegate.currentArchivedFeed(*listTypes)!!.map { it.metadata.chatId }) + } + + @Test + fun `unarchiving puts the chat back in the main feed`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + f.archived.value = emptySet() + runCurrent() + assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) + assertEquals(emptyList(), f.delegate.currentArchivedFeed(*listTypes)!!) + } + + @Test + fun `with nothing archived the feed is unchanged`() = runTest { + val f = fixture(emptySet()) + assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } +} From f4f41f349678ccf5367b9fb3eca8d1df5802de14 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:23:43 -0400 Subject: [PATCH 06/39] fix(messenger): seed the mute sheet from the archived list for archived chats --- .../messenger/internal/MuteChatViewModel.kt | 6 +- .../internal/MuteChatViewerStateSeedTest.kt | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 50b2e545e0..16debf9042 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.currentArchivedChatListFeed import com.flipcash.shared.chat.currentChatListFeed import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -40,10 +41,11 @@ internal class MuteChatViewModel @Inject constructor( * * Seeds [observeViewerState] so the sheet opens with its unmute row already decided. Starting * from `null` would open it without the row and then grow it by one as the store answered. + * An archived chat is only in the archived list, so that is searched too. */ fun currentViewerState(chatId: ChatId): ViewerState? = - chatCoordinator.currentChatListFeed() - ?.firstOrNull { it.metadata.chatId == chatId } + (chatCoordinator.currentChatListFeed().orEmpty() + chatCoordinator.currentArchivedChatListFeed().orEmpty()) + .firstOrNull { it.metadata.chatId == chatId } ?.metadata?.viewerState /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt new file mode 100644 index 0000000000..6c7c6d9990 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The mute sheet opens with its unmute row already decided, so the seed must find the chat whether + * it sits in the main list or in the archived one. + */ +class MuteChatViewerStateSeedTest { + + private val chatCoordinator = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(16) { 3 }.toList()) + private val muted = ViewerState(mute = MuteState.Forever, version = 1L) + + private fun summary() = ChatSummary( + metadata = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1), + viewerState = muted, + ), + unreadCount = 0, + ) + + private fun viewModel() = MuteChatViewModel(chatCoordinator, mockk(relaxed = true), RecordingAnalytics()) + + @Test + fun `a chat in the main list seeds from it`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns listOf(summary()) + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns emptyList() + assertEquals(muted, viewModel().currentViewerState(chatId)) + } + + @Test + fun `an archived chat seeds from the archived list`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns emptyList() + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns listOf(summary()) + assertEquals(muted, viewModel().currentViewerState(chatId)) + } + + @Test + fun `a chat in neither list has no seed`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns null + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns null + assertNull(viewModel().currentViewerState(chatId)) + } +} From 9cbdeb8bedab8e401d1731994c2b66a9a9dfd8f5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:25:17 -0400 Subject: [PATCH 07/39] feat(chat): clear archive on leave and on account erasure --- .../chat/internal/RealChatCoordinator.kt | 12 +++++- .../chat/ChatCoordinatorTeardownTest.kt | 39 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 8462c33412..0717b09835 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState @@ -94,6 +95,7 @@ class RealChatCoordinator @Inject constructor( private val userManager: UserManager, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, + private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -311,7 +313,14 @@ class RealChatCoordinator @Inject constructor( * the draft is what it was. */ override suspend fun leave(chatId: ChatId): Result = - groupFeedDelegate.leave(chatId).onSuccess { draftStore.clear(chatId) } + groupFeedDelegate.leave(chatId).onSuccess { + draftStore.clear(chatId) + // Leaving clears archive the way it clears mute (rule 5). A remote leave, from another + // device, arrives through GroupFeedDelegate's roster path, which has no store: the + // record is left behind, harmlessly, because the chat is no longer a member chat and + // so is in neither feed. + archiveStore.unarchive(chatId) + } /** * Fetches both halves of the conversation list. @@ -350,6 +359,7 @@ class RealChatCoordinator @Inject constructor( // same reason the transcript does — the database is per-account, so the next login reopens // the same file — and account deletion is the one path that is meant to erase them. draftStore.clearAll() + archiveStore.clearAll() trace(tag = TAG, message = "cache cleared", type = TraceType.Process) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 290dcdf25a..179da505f6 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -9,6 +9,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.ChatIdGenerator @@ -17,6 +18,7 @@ import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.utils.network.NetworkConnectivityListener import io.mockk.coEvery @@ -50,6 +52,8 @@ class ChatCoordinatorTeardownTest { private lateinit var messageDataSource: ChatMessageDataSource private lateinit var memberDataSource: ChatMemberDataSource private lateinit var draftStore: ChatDraftStore + private lateinit var archiveStore: ChatArchiveStore + private lateinit var groupFeedDelegate: GroupFeedDelegate private lateinit var coordinator: RealChatCoordinator @Before @@ -69,6 +73,8 @@ class ChatCoordinatorTeardownTest { messageDataSource = mockk(relaxed = true) memberDataSource = mockk(relaxed = true) draftStore = mockk(relaxed = true) + archiveStore = mockk(relaxed = true) + groupFeedDelegate = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -117,8 +123,9 @@ class ChatCoordinatorTeardownTest { userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), - groupFeedDelegate = mockk(relaxed = true), + groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), + archiveStore = archiveStore, ) } @@ -159,4 +166,34 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 1) { draftStore.clearAll() } } + + /** Like drafts: logout keeps the set (the database is per-account), erasure wipes it. */ + @Test + fun `clearCache drops the archive set, teardown keeps it`() = runTest { + coordinator.teardown() + coVerify(exactly = 0) { archiveStore.clearAll() } + + coordinator.clearCache() + coVerify(exactly = 1) { archiveStore.clearAll() } + } + + @Test + fun `a successful leave unarchives the chat`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.success(Unit) + + coordinator.leave(chatId) + + coVerify(exactly = 1) { archiveStore.unarchive(chatId) } + } + + @Test + fun `a failed leave keeps the archive record`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.failure(RuntimeException("nope")) + + coordinator.leave(chatId) + + coVerify(exactly = 0) { archiveStore.unarchive(any()) } + } } From 7c14a772ed83b1a32ea2aa120d05670d1f8a75be Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:26:54 -0400 Subject: [PATCH 08/39] refactor(chat): move mention and link detection into shared:chat --- .../src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt | 0 .../main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apps/flipcash/shared/{chat-ui => chat}/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt (100%) rename apps/flipcash/shared/{chat-ui => chat}/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt (100%) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt similarity index 100% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt similarity index 100% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt From e7aed6e464c9f03f81e361a803c3ddf7ad469e42 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:34:42 -0400 Subject: [PATCH 09/39] feat(notifications): keep archived chats silent unless the viewer is mentioned or replied to An archived chat's message that neither @mentions the viewer nor replies to one of their messages is posted quietly (no sound, vibration or heads-up) while the app is in the background, and not posted while it is in the foreground, as iOS delivers it passive. Muted chats still post nothing. --- .../shared/notifications/build.gradle.kts | 1 + .../app/notifications/NotificationService.kt | 65 ++++++++- .../flipcash/app/notifications/PushAction.kt | 9 +- .../app/notifications/PushHandlingPlanner.kt | 31 ++++- .../notifications/PushMessageClassifier.kt | 78 +++++++++++ .../notifications/PushHandlingPlannerTest.kt | 94 +++++++++++++ .../PushMessageClassifierTest.kt | 125 ++++++++++++++++++ 7 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt create mode 100644 apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts index d16d684bd4..6ea8741810 100644 --- a/apps/flipcash/shared/notifications/build.gradle.kts +++ b/apps/flipcash/shared/notifications/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(libs.coil3.core) implementation(libs.androidx.datastore) + implementation(libs.androidx.lifecycle.process) testImplementation(kotlin("test")) testImplementation(libs.robolectric) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index f256e8e50b..987698be0b 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -17,6 +17,8 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult @@ -27,6 +29,7 @@ import com.flipcash.app.auth.AuthManager import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.ContactResolver import com.flipcash.app.core.util.Linkify +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -57,6 +60,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import java.security.SecureRandom import javax.inject.Inject @@ -127,6 +131,12 @@ class NotificationService : FirebaseMessagingService(), @Inject lateinit var chatMetadataDataSource: ChatMetadataDataSource + @Inject + lateinit var archiveStore: ChatArchiveStore + + @Inject + lateinit var pushMessageClassifier: PushMessageClassifier + // TODO(firebase-messaging): 25.1.0 deprecated onNewToken in favor of FID-based onRegistered(). // Migrate once Firebase ships a stable guide and the backend accepts FID registration. // Tracking: https://github.com/firebase/firebase-android-sdk/issues/8087 @@ -158,10 +168,31 @@ class NotificationService : FirebaseMessagingService(), .takeIf { it.isNotEmpty() } ?.let { NotificationPayload.fromEncoded(it) } + // Classification reads Room, so it needs a coroutine; this function is not one and must + // stay synchronous: FCM may treat the message as handled, and let the process go, once it + // returns. A data-only push never posts, so it skips the reads; a chat that is not archived + // costs one primary-key read and never reads the message. + val chatId = (payload?.navigation as? NavigationTrigger.Chat.ById)?.chatId + val (archived, classification) = if (title == null || chatId == null) { + false to PushClassification.None + } else { + runBlocking { + val isArchived = archiveStore.isArchived(chatId) + isArchived to if (isArchived) { + pushMessageClassifier.classify(chatId, payload.chatMetadata) + } else { + PushClassification.None + } + } + } + val actions = planPushHandling( title = title, body = body, payload = payload, + archived = archived, + mentionsViewer = classification.mentionsViewer, + repliesToViewer = classification.repliesToViewer, ) val latencyMs = System.currentTimeMillis() - message.sentTime @@ -177,6 +208,7 @@ class NotificationService : FirebaseMessagingService(), "has_body" to (body != null) "actions" to actions.size "silent" to (title == null) + "archived" to archived "bucket" to bucket "latency_ms" to latencyMs "priority" to message.priority @@ -232,7 +264,7 @@ class NotificationService : FirebaseMessagingService(), val resolvedBody = post.body?.let { applySubstitutions(it, post.payload?.bodySubstitutions.orEmpty()) } - postNotification(resolvedTitle, resolvedBody, post.payload) + postNotification(resolvedTitle, resolvedBody, post.payload, post.silent) } } catch (e: Exception) { trace(tag = "NotificationService", message = "Failed to handle push", error = e) @@ -241,7 +273,16 @@ class NotificationService : FirebaseMessagingService(), } } - private suspend fun postNotification(title: String, body: String?, payload: NotificationPayload?) { + /** + * @param silent post without sound, vibration or heads-up, and not at all while the app is in + * the foreground: an archived chat's message that is not addressed to the viewer. + */ + private suspend fun postNotification( + title: String, + body: String?, + payload: NotificationPayload?, + silent: Boolean = false, + ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -260,6 +301,9 @@ class NotificationService : FirebaseMessagingService(), } if (chatId != null && chatCoordinator.isActiveChat(chatId)) return + // iOS suppresses a passive push entirely while the app is in front; the chat list already + // shows the message. + if (silent && isAppInForeground()) return // Resolved once, here, because the tap target and the message style both turn on what // kind of chat this is and neither can take the payload's word for it. @@ -276,8 +320,17 @@ class NotificationService : FirebaseMessagingService(), val group = planNotificationGroup(payloadGroupKey = groupKey, isChat = isChat) val builder = NotificationCompat.Builder(this, channel.id) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .apply { + if (silent) { + // Overrides the channel's sound and vibration for this one notification, and + // without an alert there is no heads-up: it lands in the shade only. + setSilent(true) + setPriority(NotificationCompat.PRIORITY_LOW) + } else { + setPriority(NotificationCompat.PRIORITY_HIGH) + setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + } + } .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) .setAutoCancel(true) @@ -455,6 +508,10 @@ class NotificationService : FirebaseMessagingService(), * the same profiles, but it is fire-and-forget by design (it must not stall Paging), and a * notification has to render now. */ + /** Whether any of the app's activities is visible, as iOS's foreground delegate sees it. */ + private fun isAppInForeground(): Boolean = + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + private suspend fun resolveSenderProfile(userId: ID): UserProfile? = userProfileDataSource.getCachedProfile(userId) ?: profileController.getProfileForUser(userId).getOrNull() diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt index 21f1084148..3ef4df3790 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt @@ -33,10 +33,17 @@ sealed interface PushAction { /** Refresh the contact list. */ data object SyncContacts : PushAction - /** Post a user-visible notification. Absent for a silent push. */ + /** + * Post a user-visible notification. Absent for a silent push. + * + * [silent] is set for an archived chat's message that is not addressed to the viewer: it is + * posted without sound, vibration or heads-up while the app is in the background, and not at + * all while it is in the foreground (iOS delivers these as passive). + */ data class PostNotification( val title: String, val body: String?, val payload: NotificationPayload?, + val silent: Boolean = false, ) : PushAction } diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt index fac6fb9ffc..0e99f4512e 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt @@ -3,6 +3,7 @@ package com.flipcash.app.notifications import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.shared.chat.shouldNotify /** * Decides what a received push should cause the app to do. @@ -13,18 +14,40 @@ import com.flipcash.services.models.NotificationPayload * @param title resolved push title, null for a data-only push * @param body resolved push body, may be null even for a visible push * @param payload decoded [NotificationPayload], null when absent or undecodable + * @param archived whether the viewer has this chat archived, read from local storage: the push + * payload carries no archive flag yet + * @param mentionsViewer whether the message text @mentions the viewer; false when it cannot be told + * @param repliesToViewer whether the message replies to one of the viewer's messages; false when + * the cited message is not stored locally */ fun planPushHandling( title: String?, body: String?, payload: NotificationPayload?, + archived: Boolean = false, + mentionsViewer: Boolean = false, + repliesToViewer: Boolean = false, ): List { val sync = syncActionsFor(payload) - // The sync half is the same either way; a title adds the notification on top, unless the - // chat was silenced when the push was sent. - if (title == null || isMuted(payload)) return sync - return sync + PushAction.PostNotification(title, body, payload) + // The sync half is the same either way; a title adds the notification on top. Archived chats + // still sync: the message must reach the database either way. + if (title == null) return sync + val muted = isMuted(payload) + val notify = shouldNotify( + archived = archived, + muted = muted, + mentionsViewer = mentionsViewer, + repliesToViewer = repliesToViewer, + ) + return when { + notify -> sync + PushAction.PostNotification(title, body, payload) + // A chat silenced when the push was sent shows nothing, archived or not. + muted -> sync + // An archived chat's message that is not addressed to the viewer is posted quietly rather + // than dropped, as iOS delivers it passive. + else -> sync + PushAction.PostNotification(title, body, payload, silent = true) + } } /** diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt new file mode 100644 index 0000000000..c5d9ec6abd --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.notifications + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.PushChatMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ui.detectMentions +import com.flipcash.shared.chat.ui.detectUrls +import javax.inject.Inject + +/** + * Whether a received message is addressed to the viewer: the two ways a push breaks through an + * archived chat's silence (rule 3). + */ +data class PushClassification( + val mentionsViewer: Boolean, + val repliesToViewer: Boolean, +) { + companion object { + /** Not addressed to the viewer, or not knowable. The silent default. */ + val None = PushClassification(mentionsViewer = false, repliesToViewer = false) + } +} + +/** + * Reads a push's message against what this device knows. + * + * Both answers default to false when the device cannot tell, which is rule 3's "stays silent": + * - the message is not in the payload and not yet stored, or has no readable text (encrypted, + * cash, media without a caption), so there is nothing to scan; + * - the viewer has no username, so no handle can be theirs; + * - the replied-to message is not stored locally, so its author is unknown. + * + * Mentions are `@handle` text found by [detectMentions], compared case-insensitively with the + * viewer's username. A reply is a [MessageContent.Reply] whose cited message was sent by the + * viewer. + */ +class PushMessageClassifier @Inject constructor( + private val userManager: UserManager, + private val messages: ChatMessageDataSource, +) { + + suspend fun classify(chatId: ChatId, metadata: PushChatMetadata?): PushClassification { + val message = metadata?.message + ?: metadata?.messageId?.let { messages.getMessage(chatId, it) } + ?: return PushClassification.None + + return PushClassification( + mentionsViewer = mentionsViewer(message), + repliesToViewer = repliesToViewer(chatId, message), + ) + } + + private fun mentionsViewer(message: ChatMessage): Boolean { + val handle = userManager.profile?.username?.lowercase()?.takeIf { it.isNotEmpty() } ?: return false + return message.content.texts().any { text -> + detectMentions(text, detectUrls(text)).any { it.username == handle } + } + } + + private suspend fun repliesToViewer(chatId: ChatId, message: ChatMessage): Boolean { + val selfId = userManager.accountId ?: return false + val cited = message.content.filterIsInstance().firstOrNull() ?: return false + return messages.getMessage(chatId, cited.repliedMessageId)?.senderId == selfId + } + + /** Every piece of plain text a message carries: its body, a reply's nested body, a caption. */ + private fun List.texts(): List = flatMap { content -> + when (content) { + is MessageContent.Text -> listOf(content.text) + is MessageContent.Reply -> content.content.filterIsInstance().map { it.text } + is MessageContent.Media -> listOfNotNull(content.caption?.text) + else -> emptyList() + } + } +} diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt index 3973600c4f..a26970476c 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt @@ -12,6 +12,7 @@ import com.getcode.solana.keys.Mint import kotlin.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class PushHandlingPlannerTest { @@ -260,6 +261,99 @@ class PushHandlingPlannerTest { // endregion + // region An archived chat + + private val chatNav = NavigationTrigger.Chat.ById(ChatId("aabbccdd")) + + private fun post(actions: List) = + actions.filterIsInstance().singleOrNull() + + @Test + fun `an archived chat posts an ordinary message silently`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertEquals(true, post(actions)?.silent) + } + + @Test + fun `an archived chat still syncs the message it was sent`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertTrue(actions.any { it is PushAction.ApplyMessage }) + } + + @Test + fun `an archived chat posts normally when the message mentions the viewer`() { + val actions = planPushHandling( + title = "Ada", body = "hi @me", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + mentionsViewer = true, + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `an archived chat posts normally when the message replies to the viewer`() { + val actions = planPushHandling( + title = "Ada", body = "yes", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + repliesToViewer = true, + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `a muted and archived chat posts nothing even for a mention`() { + val actions = planPushHandling( + title = "Ada", body = "hi @me", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage(), muted = true)), + archived = true, + mentionsViewer = true, + repliesToViewer = true, + ) + assertNull(post(actions)) + assertTrue(actions.any { it is PushAction.ApplyMessage }) + } + + @Test + fun `a muted and archived chat posts nothing for an ordinary message`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage(), muted = true)), + archived = true, + ) + assertNull(post(actions)) + } + + @Test + fun `a chat that is not archived posts normally as before`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `a data-only push for an archived chat posts nothing`() { + val actions = planPushHandling( + title = null, body = null, + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertNull(post(actions)) + } + + // endregion + companion object { private val TEST_MINT = Mint.usdc } diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt new file mode 100644 index 0000000000..453e02ec92 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt @@ -0,0 +1,125 @@ +package com.flipcash.app.notifications + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.PushChatMetadata +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * Rule 3's two ways in, and the default when the device cannot tell. Runs under Robolectric because + * mention detection reads `android.util.Patterns` for the links it must skip. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class PushMessageClassifierTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + private val messages = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns UserProfile.Empty.copy(username = "Bmc") + } + private val classifier = PushMessageClassifier(userManager, messages) + + private fun message(vararg content: MessageContent, id: Long = 10) = ChatMessage( + messageId = id, + senderId = otherId, + content = content.toList(), + timestamp = Instant.fromEpochSeconds(1), + unreadSeq = 1, + ) + + private fun metadata(message: ChatMessage?, messageId: Long? = null) = PushChatMetadata( + sendingUserId = otherId, + chatType = ChatType.TIP_DM, + message = message, + messageId = messageId, + ) + + @Test + fun `a mention of the viewer is recognised whatever its case`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @BMC look")))) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `a mention of someone else is not a mention of the viewer`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @ada")))) + assertEquals(PushClassification.None, result) + } + + @Test + fun `an email address is not a mention`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("write bmc@bmc.dev")))) + assertEquals(PushClassification.None, result) + } + + @Test + fun `a mention in a media caption is recognised`() = runTest { + val media = MessageContent.Media(items = emptyList(), caption = MessageContent.Text("for @bmc")) + val result = classifier.classify(chatId, metadata(message(media))) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `a reply to the viewer's own message is a reply to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("mine"), id = 7).copy(senderId = selfId) + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + val result = classifier.classify(chatId, metadata(reply)) + assertEquals(PushClassification(mentionsViewer = false, repliesToViewer = true), result) + } + + @Test + fun `a reply to someone else's message is not a reply to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("theirs"), id = 7) + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(reply))) + } + + @Test + fun `a reply whose target is not stored locally counts as not addressed to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns null + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(reply))) + } + + @Test + fun `no message text at all is unknown and so false`() = runTest { + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(message = null, messageId = null))) + val cash = MessageContent.Cash(intentId = listOf(9), amount = Fiat(quarks = 100L), mint = Mint.usdc) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(message(cash)))) + } + + @Test + fun `a message that is only stored, not inlined, is read from the database`() = runTest { + coEvery { messages.getMessage(chatId, 10) } returns message(MessageContent.Text("hi @bmc")) + val result = classifier.classify(chatId, metadata(message = null, messageId = 10)) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `a viewer with no username cannot be mentioned`() = runTest { + every { userManager.profile } returns UserProfile.Empty + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @bmc")))) + assertEquals(PushClassification.None, result) + } +} From c293c0accd5b29730b86b7bfa29f8431daec12a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:42:07 -0400 Subject: [PATCH 10/39] fix(notifications): decide archived pushes after sign-in and open encrypted DMs first The archive read and the mention/reply classification ran before authentication, when a cold start from a push has not opened the per-user database or loaded the viewer's profile, so an archived chat's push posted loudly. The planner now exposes planNotification, which the post path calls after authentication to re-decide. The classifier read inlined DM content as received, so a mention or reply in an encrypted DM never counted. It now opens the message through the new ChatCoordinator.openPushedChatMessage first. --- .../flipcash/shared/chat/ChatCoordinator.kt | 9 +++ .../internal/delegates/MessagingDelegate.kt | 12 +++- .../chat/MessagingPushDecryptionTest.kt | 15 ++++ .../app/notifications/NotificationService.kt | 71 ++++++++++--------- .../app/notifications/PushHandlingPlanner.kt | 38 +++++++--- .../notifications/PushMessageClassifier.kt | 18 ++++- .../notifications/PushHandlingPlannerTest.kt | 8 +++ .../PushMessageClassifierTest.kt | 32 ++++++++- 8 files changed, 152 insertions(+), 51 deletions(-) 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 1020f588db..a14bd9e772 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 @@ -398,6 +398,15 @@ interface MessagingOperations { */ suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? + /** + * The end-to-end encrypted message a push is for, opened on this device, with its content in + * the clear, so the push can be read for what it is (a reply, a mention) and not just shown. + * Resolves [message] or [messageId] the way [openPushedMessage] does. + * + * `null` when the message isn't encrypted or can't be opened or fetched. Nothing is stored. + */ + suspend fun openPushedChatMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): ChatMessage? + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 245e1f4739..4032d41fb7 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -290,7 +290,14 @@ class MessagingDelegate @Inject constructor( } } - override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? { + override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? = + openPushedChatMessage(chatId, message, messageId)?.content?.singleOrNull()?.pushText() + + override suspend fun openPushedChatMessage( + chatId: ChatId, + message: ChatMessage?, + messageId: Long?, + ): ChatMessage? { val opener = incoming ?: return null val selfId = userManager.accountId ?: return null val candidate = message @@ -310,8 +317,7 @@ class MessagingDelegate @Inject constructor( } else -> return null } - if (opened.encryption !is MessageEncryption.Decrypted) return null - return opened.content.singleOrNull()?.pushText() + return opened.takeIf { it.encryption is MessageEncryption.Decrypted } } /** Text and replies with text are what DMs encrypt; anything else keeps the server's body. */ diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index e5e7c45b8f..d71a63ea67 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -89,6 +89,21 @@ class MessagingPushDecryptionTest { assertEquals("yes", delegate.openPushedMessage(chatId, pushed, messageId = null)) } + @Test + fun `an opened reply keeps the message it cites`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 2, content = listOf(MessageContent.Text("yes"))) + val pushed = message(sealedFromPeer(reply)) + + val opened = delegate.openPushedChatMessage(chatId, pushed, messageId = null) + + assertEquals(listOf(reply), opened?.content) + } + + @Test + fun `a message that isn't encrypted is not opened`() = runTest { + assertNull(delegate.openPushedChatMessage(chatId, message(MessageContent.Text("hi")), messageId = null)) + } + @Test fun `an id-only push fetches the message when it isn't stored`() = runTest { val fetched = message(sealedFromPeer(MessageContent.Text("long one")), id = 9) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 987698be0b..0ae1ab8bfe 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -60,7 +60,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import java.security.SecureRandom import javax.inject.Inject @@ -168,31 +167,12 @@ class NotificationService : FirebaseMessagingService(), .takeIf { it.isNotEmpty() } ?.let { NotificationPayload.fromEncoded(it) } - // Classification reads Room, so it needs a coroutine; this function is not one and must - // stay synchronous: FCM may treat the message as handled, and let the process go, once it - // returns. A data-only push never posts, so it skips the reads; a chat that is not archived - // costs one primary-key read and never reads the message. - val chatId = (payload?.navigation as? NavigationTrigger.Chat.ById)?.chatId - val (archived, classification) = if (title == null || chatId == null) { - false to PushClassification.None - } else { - runBlocking { - val isArchived = archiveStore.isArchived(chatId) - isArchived to if (isArchived) { - pushMessageClassifier.classify(chatId, payload.chatMetadata) - } else { - PushClassification.None - } - } - } - + // Planned without the archive inputs: they come from the per-user database, which a cold + // start from a push has not opened yet. The post path re-decides once authenticated. val actions = planPushHandling( title = title, body = body, payload = payload, - archived = archived, - mentionsViewer = classification.mentionsViewer, - repliesToViewer = classification.repliesToViewer, ) val latencyMs = System.currentTimeMillis() - message.sentTime @@ -208,7 +188,6 @@ class NotificationService : FirebaseMessagingService(), "has_body" to (body != null) "actions" to actions.size "silent" to (title == null) - "archived" to archived "bucket" to bucket "latency_ms" to latencyMs "priority" to message.priority @@ -258,13 +237,14 @@ class NotificationService : FirebaseMessagingService(), launch { try { if (syncContacts) launch { contactCoordinator.sync() } - if (post != null) { + val decided = post?.let { applyArchive(it) } + if (decided != null) { val resolvedTitle = - applySubstitutions(post.title, post.payload?.titleSubstitutions.orEmpty()) - val resolvedBody = post.body?.let { - applySubstitutions(it, post.payload?.bodySubstitutions.orEmpty()) + applySubstitutions(decided.title, decided.payload?.titleSubstitutions.orEmpty()) + val resolvedBody = decided.body?.let { + applySubstitutions(it, decided.payload?.bodySubstitutions.orEmpty()) } - postNotification(resolvedTitle, resolvedBody, post.payload, post.silent) + postNotification(resolvedTitle, resolvedBody, decided.payload, decided.silent) } } catch (e: Exception) { trace(tag = "NotificationService", message = "Failed to handle push", error = e) @@ -277,6 +257,30 @@ class NotificationService : FirebaseMessagingService(), * @param silent post without sound, vibration or heads-up, and not at all while the app is in * the foreground: an archived chat's message that is not addressed to the viewer. */ + /** + * Re-decides [post] for an archived chat. Runs after authentication, because the archive and + * the viewer's username, account id and stored messages all live in the per-user database and + * user state that sign-in opens; read earlier, an archived chat would look unarchived and + * post loudly. A chat that is not archived costs one primary-key read and keeps [post] as is. + */ + private suspend fun applyArchive(post: PushAction.PostNotification): PushAction.PostNotification? { + val chatId = (post.payload?.navigation as? NavigationTrigger.Chat.ById)?.chatId ?: return post + if (!archiveStore.isArchived(chatId)) return post + val classification = pushMessageClassifier.classify(chatId, post.payload.chatMetadata) + return planNotification( + title = post.title, + body = post.body, + payload = post.payload, + archived = true, + mentionsViewer = classification.mentionsViewer, + repliesToViewer = classification.repliesToViewer, + ) + } + + /** Whether any of the app's activities is visible, as iOS's foreground delegate sees it. */ + private fun isAppInForeground(): Boolean = + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + private suspend fun postNotification( title: String, body: String?, @@ -322,10 +326,11 @@ class NotificationService : FirebaseMessagingService(), val builder = NotificationCompat.Builder(this, channel.id) .apply { if (silent) { - // Overrides the channel's sound and vibration for this one notification, and - // without an alert there is no heads-up: it lands in the shade only. + // androidx implements this by giving the notification GROUP_ALERT_SUMMARY (and + // the GROUP_KEY_SILENT group when it has none), so it never alerts whatever the + // channel's importance: no sound, vibration or heads-up, the shade only. The + // group summary below is GROUP_ALERT_CHILDREN, so it stays quiet too. setSilent(true) - setPriority(NotificationCompat.PRIORITY_LOW) } else { setPriority(NotificationCompat.PRIORITY_HIGH) setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) @@ -508,10 +513,6 @@ class NotificationService : FirebaseMessagingService(), * the same profiles, but it is fire-and-forget by design (it must not stall Paging), and a * notification has to render now. */ - /** Whether any of the app's activities is visible, as iOS's foreground delegate sees it. */ - private fun isAppInForeground(): Boolean = - ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) - private suspend fun resolveSenderProfile(userId: ID): UserProfile? = userProfileDataSource.getCachedProfile(userId) ?: profileController.getProfileForUser(userId).getOrNull() diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt index 0e99f4512e..d6e7d14072 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt @@ -28,11 +28,34 @@ fun planPushHandling( mentionsViewer: Boolean = false, repliesToViewer: Boolean = false, ): List { - val sync = syncActionsFor(payload) - // The sync half is the same either way; a title adds the notification on top. Archived chats // still sync: the message must reach the database either way. - if (title == null) return sync + val sync = syncActionsFor(payload) + val post = planNotification(title, body, payload, archived, mentionsViewer, repliesToViewer) + return if (post == null) sync else sync + post +} + +/** + * The notification half of [planPushHandling] on its own, for the caller that can only learn + * [archived], [mentionsViewer] and [repliesToViewer] later: the per-user database they are read + * from opens with authentication, which a cold start from a push has not done yet. + * + * - Muted when the push was sent: nothing, archived or not. + * - Otherwise, when [shouldNotify] allows it: a normal notification. + * - An archived chat's message not addressed to the viewer: a [PushAction.PostNotification.silent] + * one rather than none, as iOS delivers it passive. + * + * `null` for a data-only push ([title] null). + */ +fun planNotification( + title: String?, + body: String?, + payload: NotificationPayload?, + archived: Boolean = false, + mentionsViewer: Boolean = false, + repliesToViewer: Boolean = false, +): PushAction.PostNotification? { + if (title == null) return null val muted = isMuted(payload) val notify = shouldNotify( archived = archived, @@ -41,12 +64,9 @@ fun planPushHandling( repliesToViewer = repliesToViewer, ) return when { - notify -> sync + PushAction.PostNotification(title, body, payload) - // A chat silenced when the push was sent shows nothing, archived or not. - muted -> sync - // An archived chat's message that is not addressed to the viewer is posted quietly rather - // than dropped, as iOS delivers it passive. - else -> sync + PushAction.PostNotification(title, body, payload, silent = true) + notify -> PushAction.PostNotification(title, body, payload) + muted -> null + else -> PushAction.PostNotification(title, body, payload, silent = true) } } diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt index c5d9ec6abd..804bd2e3f8 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ui.detectMentions import com.flipcash.shared.chat.ui.detectUrls import javax.inject.Inject @@ -28,24 +29,35 @@ data class PushClassification( * Reads a push's message against what this device knows. * * Both answers default to false when the device cannot tell, which is rule 3's "stays silent": - * - the message is not in the payload and not yet stored, or has no readable text (encrypted, - * cash, media without a caption), so there is nothing to scan; + * - the message is not in the payload and not yet stored, or has no readable text (cash, media + * without a caption), so there is nothing to scan; + * - the message is end-to-end encrypted and cannot be opened on this device; * - the viewer has no username, so no handle can be theirs; * - the replied-to message is not stored locally, so its author is unknown. * * Mentions are `@handle` text found by [detectMentions], compared case-insensitively with the * viewer's username. A reply is a [MessageContent.Reply] whose cited message was sent by the * viewer. + * + * An encrypted DM message is opened first through [ChatCoordinator.openPushedChatMessage], so a + * mention or reply inside it counts; the push payload's copy and a stored row may both be sealed. */ class PushMessageClassifier @Inject constructor( private val userManager: UserManager, private val messages: ChatMessageDataSource, + private val chatCoordinator: ChatCoordinator, ) { suspend fun classify(chatId: ChatId, metadata: PushChatMetadata?): PushClassification { - val message = metadata?.message + val received = metadata?.message ?: metadata?.messageId?.let { messages.getMessage(chatId, it) } ?: return PushClassification.None + val message = if (received.content.singleOrNull() is MessageContent.Encrypted) { + chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) + ?: return PushClassification.None + } else { + received + } return PushClassification( mentionsViewer = mentionsViewer(message), diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt index a26970476c..f921a3da8f 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt @@ -342,6 +342,14 @@ class PushHandlingPlannerTest { assertEquals(false, post(actions)?.silent) } + @Test + fun `the notification half re-decides an archived chat on its own`() { + val p = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())) + assertEquals(PushAction.PostNotification("Ada", "hi", p, silent = true), planNotification("Ada", "hi", p, archived = true)) + assertEquals(PushAction.PostNotification("Ada", "hi", p), planNotification("Ada", "hi", p, archived = true, mentionsViewer = true)) + assertEquals(post(planPushHandling("Ada", "hi", p)), planNotification("Ada", "hi", p)) + } + @Test fun `a data-only push for an archived chat posts nothing`() { val actions = planPushHandling( diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt index 453e02ec92..79e176c1a3 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import io.mockk.coEvery @@ -38,7 +39,10 @@ class PushMessageClassifierTest { every { it.accountId } returns selfId every { it.profile } returns UserProfile.Empty.copy(username = "Bmc") } - private val classifier = PushMessageClassifier(userManager, messages) + private val chatCoordinator = mockk(relaxed = true) + private val classifier = PushMessageClassifier(userManager, messages, chatCoordinator) + + private val sealed = MessageContent.Encrypted(scheme = 1, nonce = byteArrayOf(1), ciphertext = byteArrayOf(2)) private fun message(vararg content: MessageContent, id: Long = 10) = ChatMessage( messageId = id, @@ -116,6 +120,32 @@ class PushMessageClassifierTest { assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) } + @Test + fun `an encrypted message is opened before it is read for a mention`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns + message(MessageContent.Text("hey @bmc")) + val result = classifier.classify(chatId, metadata(pushed)) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `an encrypted reply to the viewer is a reply to the viewer once opened`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns + message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("mine"), id = 7).copy(senderId = selfId) + val result = classifier.classify(chatId, metadata(pushed)) + assertEquals(PushClassification(mentionsViewer = false, repliesToViewer = true), result) + } + + @Test + fun `an encrypted message that cannot be opened is not addressed to the viewer`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns null + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(pushed))) + } + @Test fun `a viewer with no username cannot be mentioned`() = runTest { every { userManager.profile } returns UserProfile.Empty From 20608bbddd2fbe86ab2cc10411e5aebd60fb3d64 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:43:53 -0400 Subject: [PATCH 11/39] fix(notifications): post an archived push silently when opening its DM throws The classifier now treats a throw from openPushedChatMessage as an unopened message, so the push is classified as not addressed to the viewer and posts silently instead of being dropped by execute's outer catch. Also moves the postNotification silent KDoc back onto postNotification. --- .../com/flipcash/app/notifications/NotificationService.kt | 8 ++++---- .../flipcash/app/notifications/PushMessageClassifier.kt | 3 ++- .../app/notifications/PushMessageClassifierTest.kt | 8 ++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 0ae1ab8bfe..1e9aae8bee 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -253,10 +253,6 @@ class NotificationService : FirebaseMessagingService(), } } - /** - * @param silent post without sound, vibration or heads-up, and not at all while the app is in - * the foreground: an archived chat's message that is not addressed to the viewer. - */ /** * Re-decides [post] for an archived chat. Runs after authentication, because the archive and * the viewer's username, account id and stored messages all live in the per-user database and @@ -281,6 +277,10 @@ class NotificationService : FirebaseMessagingService(), private fun isAppInForeground(): Boolean = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + /** + * @param silent post without sound, vibration or heads-up, and not at all while the app is in + * the foreground: an archived chat's message that is not addressed to the viewer. + */ private suspend fun postNotification( title: String, body: String?, diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt index 804bd2e3f8..9e29f51ae9 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt @@ -53,7 +53,8 @@ class PushMessageClassifier @Inject constructor( ?: metadata?.messageId?.let { messages.getMessage(chatId, it) } ?: return PushClassification.None val message = if (received.content.singleOrNull() is MessageContent.Encrypted) { - chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) + runCatching { chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) } + .getOrNull() ?: return PushClassification.None } else { received diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt index 79e176c1a3..ae7eeb3a77 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt @@ -146,6 +146,14 @@ class PushMessageClassifierTest { assertEquals(PushClassification.None, classifier.classify(chatId, metadata(pushed))) } + @Test + fun `an encrypted message whose opening throws is not addressed to the viewer`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } throws + IllegalStateException("decryption failed") + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(pushed))) + } + @Test fun `a viewer with no username cannot be mentioned`() = runTest { every { userManager.profile } returns UserProfile.Empty From b9b7093e2fef0ee19077ec5bdcf270327b5e6aff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 17:44:53 -0400 Subject: [PATCH 12/39] feat(ui): add a selectable FilterChip --- .../com/getcode/ui/components/FilterChip.kt | 53 +++++++++++++++ .../getcode/ui/components/FilterChipTest.kt | 64 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt new file mode 100644 index 0000000000..859faf31e3 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt @@ -0,0 +1,53 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import com.getcode.theme.CodeTheme + +/** + * A chip that can be selected, for choosing one filter among a few. [CodeChip] is a label with no + * click or selected state, so this wraps it rather than changing what every other chip means. + * + * Selection is exposed to accessibility (`Role.RadioButton`, selected), because the colour change + * is the only visual cue. A [count] of null or 0 draws nothing: the chips show a number only when + * there is something in the filter. + */ +@Composable +fun FilterChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + count: Int? = null, +) { + val contentColor = if (selected) CodeTheme.colors.background else CodeTheme.colors.textMain + CodeChip( + modifier = modifier + .clip(CircleShape) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + backgroundColor = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.surfaceVariant, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x3, + vertical = CodeTheme.dimens.grid.x1, + ), + ) { + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = contentColor, + ) + if (count != null && count > 0) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = contentColor.copy(alpha = 0.7f), + ) + } + } +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt new file mode 100644 index 0000000000..4573e02a42 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt @@ -0,0 +1,64 @@ +package com.getcode.ui.components + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class FilterChipTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `selected state is exposed to accessibility`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Unread", selected = true, onClick = {}) + } + } + composeTestRule.onNodeWithText("Unread", useUnmergedTree = false).assertIsSelected() + } + + @Test + fun `an unselected chip is not selected`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Groups", selected = false, onClick = {}) + } + } + composeTestRule.onNodeWithText("Groups").assertIsNotSelected() + } + + @Test + fun `clicking invokes onClick`() { + var clicks = 0 + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "All", selected = false, onClick = { clicks++ }) + } + } + composeTestRule.onNodeWithText("All").performClick() + assertEquals(1, clicks) + } + + @Test + fun `a count is drawn after the label and a zero count is not drawn`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Unread", selected = false, onClick = {}, count = 5) + FilterChip(label = "Groups", selected = false, onClick = {}, count = 0) + } + } + composeTestRule.onNodeWithText("5").assertExists() + composeTestRule.onNodeWithText("0").assertDoesNotExist() + } +} From a73d6eab37bda542dff470725d2d5586ec070362 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:25:00 -0400 Subject: [PATCH 13/39] fix(ui): match the iOS filter chip's padding and count colour 7dp vertical padding and the count at full opacity, as iOS draws them. --- .../src/main/kotlin/com/getcode/ui/components/FilterChip.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt index 859faf31e3..577aa4a96c 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme /** @@ -34,7 +35,8 @@ fun FilterChip( backgroundColor = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.surfaceVariant, contentPadding = PaddingValues( horizontal = CodeTheme.dimens.grid.x3, - vertical = CodeTheme.dimens.grid.x1, + // 7dp to match iOS's selectable chip; no grid step lands on it. + vertical = 7.dp, ), ) { Text( @@ -46,7 +48,7 @@ fun FilterChip( Text( text = count.toString(), style = CodeTheme.typography.textSmall, - color = contentColor.copy(alpha = 0.7f), + color = contentColor, ) } } From f28174aec2ab6232b1671ac27e01e2aa76f8bf8c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:25:01 -0400 Subject: [PATCH 14/39] feat(chat): project the Chats list into filters and an Archived row ChatsViewModel combines the main and archived feeds and runs them through projectChatList, so the chips, the Archived row and their counts come from the one tested function. The selected filter and whether the chips have been revealed live in the ViewModel, so both survive opening a chat and coming back. Row mapping moves to ConversationMapping so the archived feed maps the same way. --- .../core/src/main/res/values/strings.xml | 14 ++ .../app/tipping/internal/ChatsViewModel.kt | 134 ++++++++++++++---- .../tipping/internal/ConversationMapping.kt | 37 +++++ .../internal/ChatsViewModelStateTest.kt | 94 ++++++++++++ 4 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 102267a2ad..f91b3c5dae 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1062,6 +1062,20 @@ No Chats Yet Start a new chat, or share your profile + + Archived + Archived, %1$d unread + All + Unread + Groups + No unread chats + No groups + All your chats are archived + Chat archived + Undo + Archive chat + Unarchive chat + Enter Flipcash username Enter the Flipcash username of the person you want to chat with diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index f6765f4ce5..bea020e90f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -6,14 +6,21 @@ import com.flipcash.app.core.data.Loadable import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatListEntry +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ChatListProjection import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.archivedChatListFeed import com.flipcash.shared.chat.chatListFeed +import com.flipcash.shared.chat.currentArchivedChatListFeed import com.flipcash.shared.chat.currentChatListFeed +import com.flipcash.shared.chat.projectChatList import com.flipcash.shared.chat.ui.ConversationReference -import com.flipcash.shared.chat.ui.toConversationReference -import com.flipcash.shared.chat.ui.unnamedGroupSender import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -21,78 +28,145 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject /** Backs the "Chats" tab: the user's tip DMs and groups. */ @HiltViewModel internal class ChatsViewModel @Inject constructor( - chatCoordinator: ChatCoordinator, + private val chatCoordinator: ChatCoordinator, userManager: UserManager, tokenCoordinator: TokenCoordinator, private val resources: ResourceHelper, dispatchers: DispatcherProvider, + private val archiveStore: ChatArchiveStore, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, ) { data class State( - // Loading until the chat feed emits — distinguishes "still loading" from "loaded, none", so - // the empty state doesn't flash on top of a list that's about to arrive. + // The main list (not archived, not hidden), in the feed's order. Loading until the feed + // emits, so the empty state doesn't flash on top of a list that's about to arrive. val chats: Loadable> = Loadable.Loading(), - ) + // The chip selected above the list. Not persisted: a cold start opens on All. + val filter: ChatListFilter = ChatListFilter.All, + // What each chip and the Archived row show, from the pure projection. + val projection: ChatListProjection = ChatListProjection.Empty, + // Whether a pull from the top has brought the chips on. Held here rather than in the + // screen so it survives opening a chat and coming back. + val chipsRevealed: Boolean = false, + ) { + /** The chips stay once revealed, and always show under a filter other than All. */ + val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All + + /** The Archived row: only under All, and only when something is archived. */ + val showsArchivedRow: Boolean + get() = filter == ChatListFilter.All && projection.archivedRowVisible + + /** Nothing in the main list and nothing archived: the full-screen prompt. */ + val hasNoChatsAtAll: Boolean + get() = chats.dataOrNull.orEmpty().isEmpty() && !projection.archivedRowVisible + + /** The main-list rows shown under the selected chip, in order. */ + val visibleChats: List + get() { + val loaded = chats.dataOrNull.orEmpty() + if (filter == ChatListFilter.All) return loaded + val wanted = projection.idsFor(filter).toSet() + return loaded.filter { it.chatId.toString() in wanted } + } + } sealed interface Event { - data class ChatsUpdated(val chats: Loadable>) : Event + data class ChatsUpdated( + val chats: Loadable>, + val projection: ChatListProjection, + ) : Event + + data class FilterSelected(val filter: ChatListFilter) : Event + + data object ChipsRevealed : Event + } + + fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) + + fun revealChips() = dispatchEvent(Event.ChipsRevealed) + + fun archive(chatId: ChatId) { + viewModelScope.launch { archiveStore.archive(chatId) } + } + + fun unarchive(chatId: ChatId) { + viewModelScope.launch { archiveStore.unarchive(chatId) } } init { - fun conversations( - summaries: List, + val selfId = { userManager.accountId } + + fun content( + main: List, + archived: List, tokens: List, - // Null on the first-frame draw, before the table has been read: asking then would - // re-fetch every sender already on disk. senderProfiles: Map?, - ): List { - val selfId = userManager.accountId - val tokensByMint = tokens.associateBy { it.address } - return summaries.map { summary -> - // The feed's group roster is usually just the viewer, so a group's last sender is - // named from the profiles the transcript resolves. Anyone not there yet is asked for - // here; SenderResolver collapses repeats, and the row re-emits when the write lands. - if (senderProfiles != null) { - summary.unnamedGroupSender(selfId, senderProfiles)?.let(chatCoordinator::requestSenderProfile) - } - summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty()) - } + ): Event.ChatsUpdated { + val mainRows = mapConversations(main, tokens, senderProfiles, selfId(), resources, chatCoordinator) + val archivedRows = mapConversations(archived, tokens, senderProfiles, selfId(), resources, chatCoordinator) + + // The projection sees every chat so the chips, the Archived row and the badge number + // come from the one function the fixture tests. Hidden chats never reach the feeds, so + // `hidden` is always false here. + fun entry(row: ConversationReference, isArchived: Boolean) = ChatListEntry( + id = row.chatId.toString(), + isGroup = row.isGroup, + lastActivityMs = row.lastActivity?.toEpochMilliseconds() ?: 0L, + archived = isArchived, + muted = row.viewerState.isMutedAt(), + hidden = false, + unread = row.unreadCount, + ) + val projection = projectChatList( + mainRows.map { entry(it, isArchived = false) } + archivedRows.map { entry(it, isArchived = true) } + ) + return Event.ChatsUpdated(Loadable.Loaded(mainRows), projection) } // On a cold launch the feed is usually built before this screen is, so draw it on the first // frame rather than waiting for the collector below to get a turn on the main thread. - chatCoordinator.currentChatListFeed()?.let { summaries -> + chatCoordinator.currentChatListFeed()?.let { main -> dispatchEvent( - Event.ChatsUpdated( - Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens(), null)) + content( + main = main, + archived = chatCoordinator.currentArchivedChatListFeed().orEmpty(), + tokens = tokenCoordinator.cachedTokens(), + senderProfiles = null, ) ) } combine( chatCoordinator.chatListFeed(), + chatCoordinator.archivedChatListFeed(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), - ::conversations, + ::content, ) // Off the main thread: on a cold launch the first mapping lands while the main thread // is drawing the app's first screens, and waiting for it holds the Chats tab blank. .flowOn(dispatchers.Default) - .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope) + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) } internal companion object { - private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { - is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + is Event.ChatsUpdated -> { state -> + state.copy(chats = event.chats, projection = event.projection) + } + // A chip can only be tapped once the row is on screen, so choosing one reveals it. + is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } + Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt new file mode 100644 index 0000000000..66c32c4d7e --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.flipcash.shared.chat.ui.unnamedGroupSender +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.opencode.model.core.ID + +/** + * Chat summaries as list rows. One definition for the Chats list and the Archived list, so a row + * looks the same in both. + */ +internal fun mapConversations( + summaries: List, + tokens: List, + // Null on the first-frame draw, before the table has been read: asking then would re-fetch + // every sender already on disk. + senderProfiles: Map?, + selfId: ID?, + resources: ResourceHelper, + chatCoordinator: ChatCoordinator, +): List { + val tokensByMint = tokens.associateBy { it.address } + return summaries.map { summary -> + // The feed's group roster is usually just the viewer, so a group's last sender is named + // from the profiles the transcript resolves. Anyone not there yet is asked for here; + // SenderResolver collapses repeats, and the row re-emits when the write lands. + if (senderProfiles != null) { + summary.unnamedGroupSender(selfId, senderProfiles)?.let(chatCoordinator::requestSenderProfile) + } + summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty()) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt new file mode 100644 index 0000000000..52a3198eb1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.core.data.Loadable +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ChatListProjection +import com.flipcash.shared.chat.ui.ConversationReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatsViewModelStateTest { + + private fun reduce(state: ChatsViewModel.State, event: ChatsViewModel.Event) = + ChatsViewModel.updateStateForEvent(event)(state) + + private val dm = ConversationReference(chatId = ChatId(byteArrayOf(1))) + private val group = ConversationReference(chatId = ChatId(byteArrayOf(2)), isGroup = true) + + private val projection = ChatListProjection.Empty.copy( + main = listOf(dm.chatId.toString(), group.chatId.toString()), + unreadChip = listOf(dm.chatId.toString()), + groupsChip = listOf(group.chatId.toString()), + archivedRowVisible = true, + archivedRowCount = 2, + ) + + private val loaded = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(dm, group)), projection), + ) + + @Test + fun `chips start hidden under All`() { + assertFalse(ChatsViewModel.State().showsChips) + assertFalse(loaded.showsChips) + } + + @Test + fun `a reveal holds across later feed updates`() { + val revealed = reduce(loaded, ChatsViewModel.Event.ChipsRevealed) + val updated = reduce( + revealed, + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(group)), projection), + ) + assertTrue(updated.chipsRevealed) + assertTrue(updated.showsChips) + } + + @Test + fun `choosing a filter shows the chips and stays revealed back on All`() { + val unread = reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)) + assertTrue(unread.showsChips) + val all = reduce(unread, ChatsViewModel.Event.FilterSelected(ChatListFilter.All)) + assertTrue(all.showsChips) + } + + @Test + fun `each filter shows its own rows in feed order`() { + assertEquals(listOf(dm, group), loaded.visibleChats) + assertEquals( + listOf(dm), + reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)).visibleChats, + ) + assertEquals( + listOf(group), + reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Groups)).visibleChats, + ) + } + + @Test + fun `the Archived row shows only under All`() { + assertTrue(loaded.showsArchivedRow) + assertFalse(reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)).showsArchivedRow) + assertFalse(reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Groups)).showsArchivedRow) + } + + @Test + fun `an empty main list with archived chats is not the no-chats prompt`() { + val archivedOnly = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(emptyList()), projection.copy(main = emptyList())), + ) + assertFalse(archivedOnly.hasNoChatsAtAll) + assertTrue(archivedOnly.visibleChats.isEmpty()) + + val nothing = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(emptyList()), ChatListProjection.Empty), + ) + assertTrue(nothing.hasNoChatsAtAll) + } +} From 0b6b8f07eeeb39e65f243bfbadd51ccc26eac36c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:25:01 -0400 Subject: [PATCH 15/39] feat(chat): filter chips, Archived row and archive swipe on the Chats list The chips are the list's first item and the list opens on item 1, so they sit under the bar until a pull from the top brings them on. ChipRevealConnection settles the release in onPreFling with one spring that starts at the finger's velocity, and stops any scroll toward the top that did not start there at item 1. The settle decision is chipSettle, a pure function. A row's swipe now offers Archive inside and Mute outside; archiving shows a Chat archived snackbar with Undo. The Archived row shows under All when anything is archived; its tap is a no-op until the Archived chats screen lands. --- .../com/flipcash/app/tipping/ChatsScreen.kt | 304 ++++++++++++++++-- .../app/tipping/internal/ChipReveal.kt | 216 +++++++++++++ .../app/tipping/internal/ChipRevealTest.kt | 197 ++++++++++++ .../app/tipping/internal/ChipSettleTest.kt | 69 ++++ 4 files changed, 763 insertions(+), 23 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 75ff7ef199..032aaae647 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,6 +1,37 @@ package com.flipcash.app.tipping import androidx.compose.foundation.Image +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarResult +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import com.flipcash.app.tipping.internal.ChipRevealConnection +import com.flipcash.app.tipping.internal.chipReveal +import com.flipcash.app.tipping.internal.chipVisibleFraction +import com.flipcash.shared.chat.ChatListFilter +import com.getcode.ui.components.FilterChip +import com.getcode.ui.components.snack.SnackData +import com.getcode.ui.components.snack.showSnackbar +import com.getcode.ui.theme.CodeSnackbar +import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,6 +95,10 @@ import com.getcode.ui.theme.ScaffoldBarPlacement /** * The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss * affordance (the root nav bar is the chrome). The tip card lives on the "You" tab. + * + * The All / Unread / Groups chips are the list's first item. The list starts on item 1, so they sit + * just out of view (under the bar, at zero alpha) until a pull from the top brings them on; see + * [ChipRevealConnection] for how a pull settles. */ @Composable fun ChatsScreen() { @@ -72,11 +107,31 @@ fun ChatsScreen() { val navigator = LocalCodeNavigator.current val chats = state.chats - val listState = rememberLazyListState() + // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips + // shown or not) survives opening a chat and coming back. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (state.showsChips) 0 else 1) + val flingBehavior = ScrollableDefaults.flingBehavior() + val chipRevealConnection = remember(listState, flingBehavior, viewModel) { + ChipRevealConnection( + listState = listState, + flingBehavior = flingBehavior, + isShown = { viewModel.stateFlow.value.showsChips }, + // Nothing to filter while loading or with no chats at all. + canReveal = { viewModel.stateFlow.value.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, + onRevealed = viewModel::revealChips, + ) + } // One row open at a time: swiping another row closes the one left revealed. val revealGroup = rememberSwipeRevealGroup() + val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + val archivedMessage = stringResource(R.string.message_chatArchived) + val undoLabel = stringResource(R.string.action_undo) + val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() CodeScaffold( + scaffoldState = scaffoldState, // The list runs the full height and passes under the title bar, which fades it out against // the background at its own edge — the same treatment the chat screen gives its message // list, rather than cutting the list off at the bar. @@ -110,6 +165,12 @@ fun ChatsScreen() { }, ) }, + // The tab bar is hoisted, so without this the snackbar draws underneath it. + snackbarHost = { hostState -> + SnackbarHost(hostState, modifier = Modifier.padding(bottom = tabBarPadding)) { data -> + CodeSnackbar(snackbarData = data) + } + }, ) { barPadding -> LazyColumn( modifier = Modifier @@ -117,28 +178,78 @@ fun ChatsScreen() { // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this // addresses the scrollable list itself. .testTag("chat_list") + // Holds the chips out of view until a pull from the top brings them on. + .chipReveal(chipRevealConnection) // End edge only — the start edge is the bar's scrim now, and a second fade there // would darken rows twice over as they pass under the title. .verticalScrollStateGradient(scrollState = listState, showAtStart = false), state = listState, + flingBehavior = flingBehavior, contentPadding = PaddingValues( // The bar's height as content padding rather than as a layout inset: the viewport // runs the full height and rows scroll under the bar, but at rest the first row // still sits clear of it. top = barPadding.calculateTopPadding(), // Clears the hoisted tab bar: keeps the last row reachable. Both paddings are - // measured out of `fillParentMaxSize`, so the empty state stays centered in the + // measured out of `fillParentMaxSize`, so the empty states stay centered in the // space the two bars leave visible. - bottom = LocalTabBarPadding.current.calculateBottomPadding(), + bottom = tabBarPadding, ), ) { - // Once the feed has loaded and there's nothing to show, the list is replaced by a - // centered prompt. - if (chats.isLoaded() && chats.data.isEmpty()) { - item { NoChatsYet(Modifier.fillParentMaxSize()) } - } else { - tipChatItems( - chats = chats.dataOrNull.orEmpty(), + item(key = "chips", contentType = "chips") { + ChatFilterRow( + modifier = Modifier.graphicsLayer { + // Fades in with the pull. Read here, in the draw phase, so following the + // finger never recomposes the row. + alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() + }, + selected = state.filter, + unreadCount = state.projection.unreadChipCount, + groupsCount = state.projection.groupsChipCount, + onSelect = { filter -> + if (!state.showsChips && listState.chipVisibleFraction() < 1f) { + // Hidden under the bar (a stray tap, or a screen reader reaching it): + // bring the row on rather than switching filters out of sight. + viewModel.revealChips() + scope.launch { listState.animateScrollToItem(0) } + } else if (filter != state.filter) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + viewModel.selectFilter(filter) + } + }, + ) + } + + // Always emitted, so item 1 exists from the first composition (including while + // loading) and the parked position has a stable anchor; empty when there is no row. + item(key = "archived", contentType = "archived") { + if (state.showsArchivedRow) { + ArchivedRow( + count = state.projection.archivedRowCount, + onClick = openArchivedChats, + ) + } + } + + val visible = state.visibleChats + when { + // A full-height spacer: with only the chips and an empty Archived slot the content + // is shorter than the viewport, the list fills backward to index 0, and the chips + // would show once rows arrive. + !chats.isLoaded() -> item(key = "loading") { Spacer(Modifier.fillParentMaxSize()) } + state.hasNoChatsAtAll -> item(key = "empty") { NoChatsYet(Modifier.fillParentMaxSize()) } + visible.isEmpty() -> item(key = "empty_filter") { + EmptyFilterState( + modifier = Modifier.fillParentMaxSize(), + message = when (state.filter) { + ChatListFilter.Unread -> R.string.title_noUnreadChats + ChatListFilter.Groups -> R.string.title_noGroupChats + ChatListFilter.All -> R.string.title_allChatsArchived + }, + ) + } + else -> tipChatItems( + chats = visible, onClick = { chat -> navigator.push( AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) @@ -147,6 +258,16 @@ fun ChatsScreen() { // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) }, + onArchive = { chat -> + viewModel.archive(chat.chatId) + // The row leaves the list at once, so offer to put it back. + scope.launch { + val host = scaffoldState.snackbarHostState + host.currentSnackbarData?.dismiss() + val result = host.showSnackbar(SnackData(message = archivedMessage, actionLabel = undoLabel)) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) + } + }, revealGroup = revealGroup, ) } @@ -154,6 +275,9 @@ fun ChatsScreen() { } } +// TODO(Task 12): push AppRoute.Messaging.ArchivedChats once the Archived chats screen exists. +private val openArchivedChats: () -> Unit = {} + /** * How far past the bar's bottom edge the scrim reaches before it is fully transparent. Rows begin * dissolving this far below the title rather than only once they meet it. @@ -206,13 +330,15 @@ private fun LazyListScope.tipChatItems( chats: List, onClick: (ConversationReference) -> Unit, onMute: (ConversationReference) -> Unit, + onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> - MuteSwipeRow( + ChatSwipeRow( isMuted = rememberIsMuted(chat.viewerState), onMute = { onMute(chat) }, + onArchive = { onArchive(chat) }, stateKey = chat.chatId, revealGroup = revealGroup, ) { @@ -227,29 +353,45 @@ private fun LazyListScope.tipChatItems( } /** - * A trailing swipe that opens the mute sheet, whichever way the chat is muted. + * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action + * outermost and fires it on a full swipe, so a full swipe still opens the mute sheet as before, and + * archive is a tap on the revealed action. * - * The icon names the state the chat would be moved out of: a crossed-out bell on an audible chat, - * and a plain one on a muted chat, where the sheet is also how the mute is cleared. It never mutes - * directly, because a mute always needs a duration. + * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible + * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never + * mutes directly, because a mute always needs a duration. It resets rather than settling open: a + * row left swiped under the sheet would still be sitting open once the sheet closed. * - * Resets rather than settling open: a full swipe opens the sheet, and a row left swiped under it - * would still be sitting open once the sheet closed. + * Archive removes the row, which disposes its swipe state, so Undo brings the chat back as a fresh, + * closed row. */ @Composable -private fun MuteSwipeRow( +private fun ChatSwipeRow( isMuted: Boolean, onMute: () -> Unit, + onArchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, content: @Composable () -> Unit, ) { - val label = stringResource( + val muteLabel = stringResource( if (isMuted) R.string.content_description_changeMute else R.string.content_description_muteChat ) + val archiveLabel = stringResource(R.string.content_description_archiveChat) SwipeActionRow( actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onArchive, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = archiveLabel, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, SwipeAction( // The profile's unverified badge treatment rather than the delete red: nothing is // lost by it. @@ -263,15 +405,18 @@ private fun MuteSwipeRow( } else { Icons.Outlined.NotificationsOff }, - contentDescription = label, + contentDescription = muteLabel, tint = CodeTheme.colors.warning, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) - } + }, ), - // A swipe is out of reach with a screen reader, so the row offers the same action there. + // A swipe is out of reach with a screen reader, so the row offers the same actions there. modifier = Modifier.semantics { - customActions = listOf(CustomAccessibilityAction(label) { onMute(); true }) + customActions = listOf( + CustomAccessibilityAction(muteLabel) { onMute(); true }, + CustomAccessibilityAction(archiveLabel) { onArchive(); true }, + ) }, stateKey = stateKey, revealGroup = revealGroup, @@ -279,6 +424,119 @@ private fun MuteSwipeRow( ) } +/** + * The filter chips. All carries no number: the tab badge already shows it. Unread and Groups show + * theirs only when non-zero. + */ +@Composable +private fun ChatFilterRow( + selected: ChatListFilter, + unreadCount: Int, + groupsCount: Int, + onSelect: (ChatListFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .selectableGroup() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + FilterChip( + label = stringResource(R.string.title_filterAll), + selected = selected == ChatListFilter.All, + onClick = { onSelect(ChatListFilter.All) }, + ) + FilterChip( + label = stringResource(R.string.title_filterUnread), + selected = selected == ChatListFilter.Unread, + onClick = { onSelect(ChatListFilter.Unread) }, + count = unreadCount, + ) + FilterChip( + label = stringResource(R.string.title_filterGroups), + selected = selected == ChatListFilter.Groups, + onClick = { onSelect(ChatListFilter.Groups) }, + count = groupsCount, + ) + } +} + +/** + * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads + * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. + */ +@Composable +private fun ArchivedRow(count: Int, onClick: () -> Unit) { + val description = if (count > 0) { + stringResource(R.string.content_description_archivedUnread, count) + } else { + stringResource(R.string.title_archived) + } + val color = CodeTheme.colors.textSecondary + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + } + .testTag("archived_row") + .padding(vertical = CodeTheme.dimens.grid.x3) + .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // Centred in the avatar column, so the label lines up with the chat names below. + Box( + modifier = Modifier.padding(start = CodeTheme.dimens.inset).width(CodeTheme.dimens.staticGrid.x8), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = null, + tint = color, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.title_archived), + style = CodeTheme.typography.textMedium, + color = color, + ) + if (count > 0) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = color, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = color, + ) + } +} + +/** A filter with nothing under it, centred in the space it is given (the list viewport). */ +@Composable +private fun EmptyFilterState(@StringRes message: Int, modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(message), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt new file mode 100644 index 0000000000..b68d4d61b9 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt @@ -0,0 +1,216 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlin.math.max +import kotlin.math.min + +/** What a released drag does to a chip row that has not been revealed yet. */ +internal enum class ChipSettle { + /** Leave the release to the list's own fling. */ + None, + + /** Settle fully on the chips, which then stay. */ + Reveal, + + /** Settle just past the chips, hiding them again. */ + Park, + + /** Fling toward the top, but stop where the chips begin. */ + ClampedFling, +} + +/** + * How far the list projects a release, in seconds of its velocity. iOS's value: a quick flick from + * the parked position reveals the chips even if the finger barely moved. + */ +private const val ProjectionSeconds = 0.1f + +/** + * Decides how a release settles while the chips are hidden. + * + * [pastPark] is how far the list sits beyond the parked position (item 1 at the top): negative + * while the chips are partly on screen, zero when parked, positive once scrolled into the list. + * [velocity] is the finger's velocity in px/s, positive when moving down (toward the top of the + * list). [pullStartedAtTop] is whether the gesture began at the parked position; only such a pull + * may reveal. + */ +internal fun chipSettle( + pastPark: Float, + chipHeight: Float, + velocity: Float, + pullStartedAtTop: Boolean, +): ChipSettle { + if (chipHeight <= 0f) return if (velocity > 0f) ChipSettle.ClampedFling else ChipSettle.None + val projected = pastPark - velocity * ProjectionSeconds + return when { + // More than half the chip row is (or would be) on screen. + pullStartedAtTop && projected < -chipHeight / 2 -> ChipSettle.Reveal + pastPark < 0f -> when { + // Flung back into the list: its own fling carries the chips away. + velocity < 0f && projected > 0f -> ChipSettle.None + else -> ChipSettle.Park + } + velocity > 0f -> ChipSettle.ClampedFling + else -> ChipSettle.None + } +} + +/** + * Keeps the chip row (item 0 of [listState]) out of view until a pull from the top reveals it. + * + * Every scroll toward the top that did not begin at the parked position stops there, in + * [onPreScroll] for drags and in a clamped fling for releases. The release of a pull settles in + * [onPreFling] with one animation that starts at the finger's velocity and then reports all of it + * consumed, so the list never runs a second fling after the settle. + */ +internal class ChipRevealConnection( + private val listState: LazyListState, + private val flingBehavior: FlingBehavior, + private val isShown: () -> Boolean, + private val canReveal: () -> Boolean, + private val onRevealed: () -> Unit, +) : NestedScrollConnection { + + /** Set on pointer down; cleared when the gesture's release is handled. */ + private var pullStartedAtTop = false + + fun onPointerDown() { + pullStartedAtTop = !isShown() && canReveal() && listState.pastPark() <= 1f + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isShown()) return Offset.Zero + val dy = available.y + // Scrolling into the list, or a pull that began at the top: the list takes it all. + if (dy <= 0f) return Offset.Zero + if (source == NestedScrollSource.UserInput && pullStartedAtTop) return Offset.Zero + val room = max(listState.pastPark(), 0f) + return if (dy > room) Offset(0f, dy - room) else Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val fromTop = pullStartedAtTop + pullStartedAtTop = false + if (isShown()) return Velocity.Zero + val pastPark = listState.pastPark() + val chipHeight = listState.chipHeight() + return when (chipSettle(pastPark, chipHeight, available.y, fromTop && canReveal())) { + ChipSettle.None -> Velocity.Zero + ChipSettle.Reveal -> { + settleBy(-(pastPark + chipHeight), velocity = -available.y) + onRevealed() + available + } + ChipSettle.Park -> { + settleBy(-pastPark, velocity = -available.y) + available + } + ChipSettle.ClampedFling -> { + clampedFling(velocity = -available.y) + available + } + } + } + + /** + * Scrolls by [delta] on a spring that starts at [velocity] (in scroll direction), so the + * release carries straight into the settle. Overshoot is held at the target rather than + * scrolled past and back. + */ + private suspend fun settleBy(delta: Float, velocity: Float) { + if (delta == 0f) return + val low = min(0f, delta) + val high = max(0f, delta) + listState.scroll { + var applied = 0f + animate( + initialValue = 0f, + targetValue = delta, + initialVelocity = velocity, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { value, _ -> + applied += scrollBy(value.coerceIn(low, high) - applied) + } + } + } + + /** The list's own fling, stopped where the chips begin. */ + private suspend fun clampedFling(velocity: Float) { + listState.scroll { + val scope = this + val clamped = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + if (pixels >= 0f) return scope.scrollBy(pixels) + // Toward the top: never past the parked position. Steps, because far from the + // top [pastPark] is only a lower bound; each step lands closer and re-measures. + var consumed = 0f + while (consumed > pixels) { + val step = max(pixels - consumed, -max(listState.pastPark(), 0f)) + if (step == 0f) break + val moved = scope.scrollBy(step) + consumed += moved + if (moved != step) break + } + // A delta not fully consumed ends the fling. + return consumed + } + } + with(flingBehavior) { clamped.performFling(velocity) } + } + } +} + +/** + * How far the list sits beyond the parked position (item 1 at the top of the content area): + * negative while the chip row is partly on screen. Item offsets are relative to the content start, + * so a chip row lying in the top padding (under the bar) still counts as hidden. + * + * Exact while item 0 or 1 is laid out, which includes the parked position: the chip row then sits + * in the top padding, where the list still composes it. Further down it is a lower bound, the top + * padding: item 1 is dropped only once it has scrolled past all of it. + */ +internal fun LazyListState.pastPark(): Float { + val items = layoutInfo.visibleItemsInfo + items.firstOrNull { it.index == 0 }?.let { return -(it.offset + it.size).toFloat() } + items.firstOrNull { it.index == 1 }?.let { return -it.offset.toFloat() } + return layoutInfo.beforeContentPadding.toFloat() +} + +/** + * Wires [connection] to the list it is placed on: records where each gesture starts, and takes + * part in the list's nested scroll. Placed before the list's own scrolling. + */ +internal fun Modifier.chipReveal(connection: ChipRevealConnection): Modifier = this + .pointerInput(connection) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + connection.onPointerDown() + } + } + .nestedScroll(connection) + +/** The chip row's height, or 0 while it is not laid out. */ +internal fun LazyListState.chipHeight(): Float = + layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 }?.size?.toFloat() ?: 0f + +/** How much of the chip row is on screen, 0 to 1. */ +internal fun LazyListState.chipVisibleFraction(): Float { + val height = chipHeight() + if (height <= 0f) return 0f + return (-pastPark() / height).coerceIn(0f, 1f) +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt new file mode 100644 index 0000000000..96678beb64 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt @@ -0,0 +1,197 @@ +package com.flipcash.app.tipping.internal + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Drives [ChipRevealConnection] on a list laid out like the Chats tab: the chip row as item 0, an + * empty slot as item 1 (where the Archived row goes), rows after, and the list padded by a bar. + * Positions are read as [pastPark]: 0 is parked, minus the chip height is fully revealed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ChipRevealTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private lateinit var listState: LazyListState + private var revealed by mutableStateOf(false) + private var firstFrame: Float? = null + + // xhdpi: 2px per dp. + private val chipPx = 100f + + private fun setContent() { + composeRule.setContent { + listState = rememberLazyListState(initialFirstVisibleItemIndex = 1) + val fling = ScrollableDefaults.flingBehavior() + val connection = remember(listState, fling) { + ChipRevealConnection( + listState = listState, + flingBehavior = fling, + isShown = { revealed }, + canReveal = { true }, + onRevealed = { revealed = true }, + ) + } + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("list").chipReveal(connection), + state = listState, + flingBehavior = fling, + contentPadding = PaddingValues(top = 56.dp), + ) { + item(key = "chips") { + Box(Modifier.fillMaxWidth().height(50.dp)) + if (firstFrame == null) firstFrame = listState.chipVisibleFraction() + } + item(key = "archived") {} + items((0 until 60).toList()) { Box(Modifier.fillMaxWidth().height(60.dp)) } + } + } + composeRule.waitForIdle() + } + + /** A drag of [dy] px that comes to rest before lifting, so it releases with no velocity. */ + private fun TouchInjectionScope.slowDrag(dy: Float) { + down(Offset(centerX, 400f)) + repeat(20) { moveBy(Offset(0f, dy / 20), delayMillis = 16) } + repeat(10) { moveBy(Offset.Zero, delayMillis = 16) } + up() + } + + private val pastPark get() = listState.pastPark() + + @Test + fun `starts parked with the chips hidden from the first frame`() { + setContent() + assertEquals(0f, firstFrame!!, 0f) + assertEquals(0f, pastPark, 0.5f) + assertEquals(chipPx, listState.chipHeight(), 0.5f) + // The empty Archived slot is the anchor, not the first row, even at zero height. + assertEquals(1, listState.firstVisibleItemIndex) + assertEquals(0, listState.firstVisibleItemScrollOffset) + } + + @Test + fun `a pull past half the chips settles revealed`() { + setContent() + // Touch slop eats the start of the drag, so pull well past half. + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a short pull settles back to parked`() { + setContent() + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.3f + 16f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a fling toward the top from mid-list stops parked`() { + setContent() + // Far enough down that the release comes well before the top: the fling has to stop. + runBlocking { listState.scrollToItem(30) } + composeRule.waitForIdle() + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithTag("list").performTouchInput { + swipeDown(startY = 200f, endY = 1_200f, durationMillis = 80) + } + var frames = 0 + while (pastPark > 0.5f && frames++ < 300) composeRule.mainClock.advanceTimeByFrame() + assertEquals(0f, pastPark, 0.5f) + // The fling ends where it stops, rather than running on with every delta held back: a + // fling still running would swallow the next tap on a row. + repeat(2) { composeRule.mainClock.advanceTimeByFrame() } + assertFalse("fling still running at park", listState.isScrollInProgress) + composeRule.mainClock.autoAdvance = true + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a drag from mid-list past the top stops parked while the finger is down`() { + setContent() + runBlocking { listState.scrollToItem(3) } + composeRule.waitForIdle() + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { + down(Offset(centerX, 300f)) + repeat(20) { moveBy(Offset(0f, 50f), delayMillis = 16) } + } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + list.performTouchInput { up() } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a pull from the top follows the finger`() { + setContent() + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { + down(Offset(centerX, 400f)) + // Past touch slop, then 40px more. + repeat(4) { moveBy(Offset(0f, 20f), delayMillis = 16) } + } + composeRule.waitForIdle() + val partway = pastPark + list.performTouchInput { moveBy(Offset(0f, 30f), delayMillis = 16) } + composeRule.waitForIdle() + assertTrue("chips partly on: $partway", partway < 0f && partway > -chipPx) + assertEquals(partway - 30f, pastPark, 0.5f) + list.performTouchInput { up() } + } + + @Test + fun `once revealed the list scrolls freely to the chips`() { + setContent() + revealed = true + runBlocking { listState.scrollToItem(8) } + composeRule.waitForIdle() + composeRule.onNodeWithTag("list").performTouchInput { + swipeDown(startY = 200f, endY = 1_200f, durationMillis = 80) + } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt new file mode 100644 index 0000000000..dd468f0dbd --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.tipping.internal + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChipSettleTest { + + private val chip = 100f + + private fun settle(pastPark: Float, velocity: Float = 0f, fromTop: Boolean = true) = + chipSettle(pastPark = pastPark, chipHeight = chip, velocity = velocity, pullStartedAtTop = fromTop) + + @Test + fun `a slow release with more than half the chips on screen reveals them`() { + assertEquals(ChipSettle.Reveal, settle(pastPark = -60f)) + } + + @Test + fun `a slow release with half or less on screen parks`() { + assertEquals(ChipSettle.Park, settle(pastPark = -50f)) + assertEquals(ChipSettle.Park, settle(pastPark = -10f)) + } + + @Test + fun `a quick flick down from a short pull reveals`() { + // -10 - 500 * 0.1 = -60: past half once projected. + assertEquals(ChipSettle.Reveal, settle(pastPark = -10f, velocity = 500f)) + } + + @Test + fun `a flick back into the list from a partial reveal parks`() { + // -60 + 300 * 0.1 = -30: not past park, so the spring brings it to park. + assertEquals(ChipSettle.Park, settle(pastPark = -60f, velocity = -300f)) + } + + @Test + fun `a hard flick back into the list leaves the list's own fling`() { + assertEquals(ChipSettle.None, settle(pastPark = -20f, velocity = -2_000f)) + } + + @Test + fun `a gesture that began mid-list never reveals`() { + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 400f, velocity = 8_000f, fromTop = false)) + assertEquals(ChipSettle.Park, settle(pastPark = -80f, fromTop = false)) + } + + @Test + fun `a fling toward the top from inside the list is clamped`() { + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 400f, velocity = 300f)) + } + + @Test + fun `a release at rest or moving into the list is left alone`() { + assertEquals(ChipSettle.None, settle(pastPark = 0f)) + assertEquals(ChipSettle.None, settle(pastPark = 400f, velocity = -3_000f)) + } + + @Test + fun `with the chips not laid out a fling toward the top is still clamped`() { + assertEquals( + ChipSettle.ClampedFling, + chipSettle(pastPark = 2_000f, chipHeight = 0f, velocity = 5_000f, pullStartedAtTop = false), + ) + assertEquals( + ChipSettle.None, + chipSettle(pastPark = 2_000f, chipHeight = 0f, velocity = -5_000f, pullStartedAtTop = false), + ) + } +} From 802cf039882f22738448bc8cb845743e2dde22ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:32:09 -0400 Subject: [PATCH 16/39] fix(chat): keep hidden chips out of reach and mark a reveal once they are on Parked, the chip row lies at zero alpha under the title bar, so a tap on the bar's lower edge reached an invisible chip and slid the row in. Touches now stop at the row while it is hidden; its semantics stay, so a screen reader can still reveal it. A reveal was recorded only once the settle spring came to rest, 300-500ms after the chips were fully on. A touch in that time cancelled the spring before the reveal was recorded, and the next scroll toward the top parked the chips again. The reveal is now recorded when the chips reach the top, or when a cancelled settle leaves more than half the row on screen. A list restored on the chips with a fresh ViewModel now counts them as revealed, and the Archived row's icon sits in the avatar column instead of one inset further in. --- .../com/flipcash/app/tipping/ChatsScreen.kt | 23 +++++-- .../app/tipping/internal/ChipReveal.kt | 39 ++++++++++-- .../app/tipping/internal/ChipRevealTest.kt | 60 ++++++++++++++++++- .../app/tipping/internal/ChipSettleTest.kt | 8 +++ 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 032aaae647..2362f296a1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.graphicsLayer @@ -24,6 +25,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import com.flipcash.app.tipping.internal.ChipRevealConnection +import com.flipcash.app.tipping.internal.blockTouchesWhile import com.flipcash.app.tipping.internal.chipReveal import com.flipcash.app.tipping.internal.chipVisibleFraction import com.flipcash.shared.chat.ChatListFilter @@ -121,6 +123,13 @@ fun ChatsScreen() { onRevealed = viewModel::revealChips, ) } + // Restored on the chips (after process death, say) with the ViewModel starting over: they are + // on screen, so they count as revealed rather than being parked by the next scroll. + LaunchedEffect(listState) { + if (listState.firstVisibleItemIndex == 0 && !viewModel.stateFlow.value.showsChips) { + viewModel.revealChips() + } + } // One row open at a time: swiping another row closes the one left revealed. val revealGroup = rememberSwipeRevealGroup() val scaffoldState = rememberScaffoldState() @@ -198,11 +207,13 @@ fun ChatsScreen() { ) { item(key = "chips", contentType = "chips") { ChatFilterRow( - modifier = Modifier.graphicsLayer { - // Fades in with the pull. Read here, in the draw phase, so following the - // finger never recomposes the row. - alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() - }, + modifier = Modifier + .blockTouchesWhile { !state.showsChips && listState.chipVisibleFraction() == 0f } + .graphicsLayer { + // Fades in with the pull. Read here, in the draw phase, so following + // the finger never recomposes the row. + alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() + }, selected = state.filter, unreadCount = state.projection.unreadChipCount, groupsCount = state.projection.groupsChipCount, @@ -492,7 +503,7 @@ private fun ArchivedRow(count: Int, onClick: () -> Unit) { ) { // Centred in the avatar column, so the label lines up with the chat names below. Box( - modifier = Modifier.padding(start = CodeTheme.dimens.inset).width(CodeTheme.dimens.staticGrid.x8), + modifier = Modifier.width(CodeTheme.dimens.staticGrid.x8), contentAlignment = Alignment.Center, ) { Icon( diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt index b68d4d61b9..5cb9397ec3 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt @@ -112,8 +112,22 @@ internal class ChipRevealConnection( return when (chipSettle(pastPark, chipHeight, available.y, fromTop && canReveal())) { ChipSettle.None -> Velocity.Zero ChipSettle.Reveal -> { - settleBy(-(pastPark + chipHeight), velocity = -available.y) - onRevealed() + var marked = false + val markRevealed = { + if (!marked) { + marked = true + onRevealed() + } + } + try { + // Revealed the moment the chips are fully on, not when the spring comes to + // rest: a touch in the spring's tail cancels it, and an unmarked row would be + // parked again by the next scroll toward the top. + settleBy(-(pastPark + chipHeight), velocity = -available.y, onReached = markRevealed) + } finally { + // Cancelled before getting there, but already mostly on: keep them. + if (listState.pastPark() <= -chipHeight / 2) markRevealed() + } available } ChipSettle.Park -> { @@ -132,8 +146,11 @@ internal class ChipRevealConnection( * release carries straight into the settle. Overshoot is held at the target rather than * scrolled past and back. */ - private suspend fun settleBy(delta: Float, velocity: Float) { - if (delta == 0f) return + private suspend fun settleBy(delta: Float, velocity: Float, onReached: () -> Unit = {}) { + if (delta == 0f) { + onReached() + return + } val low = min(0f, delta) val high = max(0f, delta) listState.scroll { @@ -145,6 +162,7 @@ internal class ChipRevealConnection( animationSpec = spring(stiffness = Spring.StiffnessMediumLow), ) { value, _ -> applied += scrollBy(value.coerceIn(low, high) - applied) + if (applied == delta) onReached() } } } @@ -191,6 +209,19 @@ internal fun LazyListState.pastPark(): Float { return layoutInfo.beforeContentPadding.toFloat() } +/** + * Lets no touch through to the chip row while [blocked]: hidden, it lies in the list's top padding + * under the title bar, and a tap on the bar's lower edge would otherwise land on an invisible chip. + * Only the pointer is stopped; the row's semantics stay, so a screen reader can still reach it. + */ +internal fun Modifier.blockTouchesWhile(blocked: () -> Boolean): Modifier = pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + // A consumed down never starts a click below. The list's own drag still starts from it. + if (blocked()) down.consume() + } +} + /** * Wires [connection] to the list it is placed on: records where each gesture starts, and takes * part in the list's nested scroll. Placed before the list's own scrolling. diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt index 96678beb64..a727a02c49 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.tipping.internal import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -19,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.click import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput @@ -49,6 +51,7 @@ class ChipRevealTest { private lateinit var listState: LazyListState private var revealed by mutableStateOf(false) private var firstFrame: Float? = null + private var chipClicks = 0 // xhdpi: 2px per dp. private val chipPx = 100f @@ -73,7 +76,13 @@ class ChipRevealTest { contentPadding = PaddingValues(top = 56.dp), ) { item(key = "chips") { - Box(Modifier.fillMaxWidth().height(50.dp)) + Box( + Modifier + .fillMaxWidth() + .height(50.dp) + .blockTouchesWhile { !revealed && listState.chipVisibleFraction() == 0f } + .clickable { chipClicks++ } + ) if (firstFrame == null) firstFrame = listState.chipVisibleFraction() } item(key = "archived") {} @@ -194,4 +203,53 @@ class ChipRevealTest { composeRule.waitForIdle() assertEquals(-chipPx, pastPark, 0.5f) } + + @Test + fun `a tap on the bar over hidden chips does not reach them`() { + setContent() + // Parked, the chip row lies in the top padding: 12px to 112px. + composeRule.onNodeWithTag("list").performTouchInput { click(Offset(centerX, 60f)) } + composeRule.waitForIdle() + assertEquals(0, chipClicks) + assertEquals(0f, pastPark, 0.5f) + + revealed = true + composeRule.waitForIdle() + composeRule.onNodeWithTag("list").performTouchInput { click(Offset(centerX, 60f)) } + composeRule.waitForIdle() + assertEquals(1, chipClicks) + } + + @Test + fun `a quick flick down from park reveals`() { + setContent() + composeRule.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 400f)) + // Barely past touch slop, but fast. + repeat(5) { moveBy(Offset(0f, 12f), delayMillis = 8) } + up() + } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a touch that cancels the settle partway still leaves the chips revealed`() { + setContent() + composeRule.mainClock.autoAdvance = false + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + repeat(2) { composeRule.mainClock.advanceTimeByFrame() } + assertFalse("settle already done", revealed) + list.performTouchInput { down(Offset(centerX, 600f)) } + repeat(3) { composeRule.mainClock.advanceTimeByFrame() } + val stoppedAt = pastPark + assertTrue("settle not cancelled: $stoppedAt", stoppedAt > -chipPx + 0.5f) + assertTrue(revealed) + list.performTouchInput { up() } + composeRule.mainClock.autoAdvance = true + composeRule.waitForIdle() + assertTrue(revealed) + } } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt index dd468f0dbd..45a647863c 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt @@ -66,4 +66,12 @@ class ChipSettleTest { chipSettle(pastPark = 2_000f, chipHeight = 0f, velocity = -5_000f, pullStartedAtTop = false), ) } + + @Test + fun `a quick flick down from park reveals`() { + // 0 - 600 * 0.1 = -60: past half once projected, with nothing pulled yet. + assertEquals(ChipSettle.Reveal, settle(pastPark = 0f, velocity = 600f)) + // Too slow to project past half: the list stays parked (a clamped fling stops at once). + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 0f, velocity = 400f)) + } } From 9b9d7721e33d8d9a36bc53977807a4d87933aaea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:34:06 -0400 Subject: [PATCH 17/39] feat(tipping): add the Archived chats screen and route --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 10 ++ .../core/src/main/res/values/strings.xml | 1 + .../app/tipping/ArchivedChatsScreen.kt | 147 ++++++++++++++++++ .../com/flipcash/app/tipping/ChatsScreen.kt | 5 +- .../internal/ArchivedChatsViewModel.kt | 69 ++++++++ 6 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.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 74571d545b..04e242cc5b 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 @@ -26,6 +26,7 @@ import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.FindByUsernameScreen +import com.flipcash.app.tipping.ArchivedChatsScreen import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.ChatsScreen @@ -121,6 +122,7 @@ fun appEntryProvider( } annotatedEntry { key -> ProfileScreen(key.address) } annotatedEntry { NewChatScreen() } + annotatedEntry { ArchivedChatsScreen() } annotatedEntry { FindByUsernameScreen() } annotatedEntry { key -> NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) 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 7150d54524..7d7c349b48 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 @@ -362,6 +362,16 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data object NewChat : Messaging + /** + * The chats the viewer archived, behind the Archived row on the Chats list. + * + * A pushed route rather than an in-place screen, for the reason [NewChat] is: the Chats + * list is a tab home, and anything drawn inside it would leave the tab bar over this one. + */ + @Serializable + @Parcelize + data object ArchivedChats : Messaging + /** * Another person's profile, opened on its own rather than from inside a chat — the * destination of a `flipcash.com/{username}` or `flipcash.com/{uuid}` link. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f91b3c5dae..ca157dad19 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1070,6 +1070,7 @@ Groups No unread chats No groups + No archived chats All your chats are archived Chat archived Undo diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt new file mode 100644 index 0000000000..6f2c8bd9d2 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -0,0 +1,147 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Unarchive +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.tipping.internal.ArchivedChatsViewModel +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.features.tipping.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup +import com.getcode.ui.theme.CodeScaffold + +/** + * The archived chats: the same row as the Chats list, sorted by last activity. A swipe on a row + * unarchives it. Opening a chat from here does not unarchive it. + */ +@Composable +fun ArchivedChatsScreen() { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + ArchivedChatsContent( + chats = state.chats.dataOrNull.orEmpty(), + isLoaded = state.chats.isLoaded(), + onBack = { navigator.pop() }, + onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, + onUnarchive = viewModel::unarchive, + ) +} + +@Composable +private fun ArchivedChatsContent( + chats: List, + isLoaded: Boolean, + onBack: () -> Unit, + onOpen: (ChatId) -> Unit, + onUnarchive: (ChatId) -> Unit, +) { + // One row open at a time, as on the Chats list. + val revealGroup = rememberSwipeRevealGroup() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_archived), + onBackIconClicked = onBack, + ) + }, + ) { padding -> + if (isLoaded && chats.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.title_noArchivedChats), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("archived_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + // Keyed by chat so a row's swipe state stays with its chat as activity reorders. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + UnarchiveSwipeRow( + onUnarchive = { onUnarchive(chat.chatId) }, + stateKey = chat.chatId, + revealGroup = revealGroup, + ) { + // The row draws its divider below itself, so none sits above the first row. + TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { + onOpen(chat.chatId) + } + } + } + } + } + } +} + +@Composable +private fun UnarchiveSwipeRow( + onUnarchive: () -> Unit, + stateKey: Any, + revealGroup: SwipeRevealGroup, + content: @Composable () -> Unit, +) { + val label = stringResource(R.string.content_description_unarchiveChat) + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onUnarchive, + ) { + Icon( + imageVector = Icons.Outlined.Unarchive, + contentDescription = label, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, + ), + // A swipe is out of reach with a screen reader, so the row offers the action there. + modifier = Modifier.semantics { + customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) + }, + stateKey = stateKey, + revealGroup = revealGroup, + content = content, + ) +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 2362f296a1..ec7dceb93f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -237,7 +237,7 @@ fun ChatsScreen() { if (state.showsArchivedRow) { ArchivedRow( count = state.projection.archivedRowCount, - onClick = openArchivedChats, + onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, ) } } @@ -286,9 +286,6 @@ fun ChatsScreen() { } } -// TODO(Task 12): push AppRoute.Messaging.ArchivedChats once the Archived chats screen exists. -private val openArchivedChats: () -> Unit = {} - /** * How far past the bar's bottom edge the scrim reaches before it is fully transparent. Rows begin * dissolving this far below the title rather than only once they meet it. diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt new file mode 100644 index 0000000000..41d7953a6d --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.archivedChatListFeed +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Backs the Archived screen: the archived chats, newest first, and unarchive. */ +@HiltViewModel +internal class ArchivedChatsViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val archiveStore: ChatArchiveStore, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + val chats: Loadable> = Loadable.Loading(), + ) + + sealed interface Event { + data class ChatsUpdated(val chats: Loadable>) : Event + } + + fun unarchive(chatId: ChatId) { + viewModelScope.launch { archiveStore.unarchive(chatId) } + } + + init { + combine( + chatCoordinator.archivedChatListFeed(), + tokenCoordinator.tokens, + chatCoordinator.observeSenderProfiles(), + ) { summaries, tokens, senderProfiles -> + mapConversations(summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator) + } + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) } + .launchIn(viewModelScope) + } + + internal companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + } + } + } +} From 34195443a6afbabdba6e40021f8ac40d16e7c967 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:40:36 -0400 Subject: [PATCH 18/39] fix(chat): park the chips on a list shorter than the screen With only a few chats, everything after the chip row was shorter than the viewport, so the list could not hold item 1 at the top. It fell back to index 0 on the first frame, so the chips showed and the list could not scroll. iOS parks regardless by setting the scroll distance to the chips' height. A last item now makes up the difference: the viewport less the height of every item after the chips. Each of those items reports its height as it is measured. The list measures forward from item 1, so in a short list the filler's height is already right in the first pass. It takes nothing once the list is longer than the screen, and nothing while any row has not been measured. So the list never scrolls further past its last row than the chip row's height. --- .../com/flipcash/app/tipping/ChatsScreen.kt | 49 +++++++++++--- .../app/tipping/internal/ChipReveal.kt | 64 +++++++++++++++++++ .../app/tipping/internal/ChipRevealTest.kt | 53 ++++++++++++++- 3 files changed, 153 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index ec7dceb93f..e4d98f9676 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.graphicsLayer @@ -25,7 +26,9 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import com.flipcash.app.tipping.internal.ChipRevealConnection +import com.flipcash.app.tipping.internal.ParkFiller import com.flipcash.app.tipping.internal.blockTouchesWhile +import com.flipcash.app.tipping.internal.rememberParkFiller import com.flipcash.app.tipping.internal.chipReveal import com.flipcash.app.tipping.internal.chipVisibleFraction import com.flipcash.shared.chat.ChatListFilter @@ -123,6 +126,18 @@ fun ChatsScreen() { onRevealed = viewModel::revealChips, ) } + val visible = state.visibleChats + // Everything between the chips and the filler, in list order. + val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { + listOf("archived") + when { + !chats.isLoaded() -> listOf("loading") + state.hasNoChatsAtAll -> listOf("empty") + visible.isEmpty() -> listOf("empty_filter") + else -> visible.map { it.chatId } + } + } + val parkFiller = rememberParkFiller() + SideEffect { parkFiller.retain(parkKeys) } // Restored on the chips (after process death, say) with the ViewModel starting over: they are // on screen, so they count as revealed rather than being parked by the next scroll. LaunchedEffect(listState) { @@ -234,24 +249,29 @@ fun ChatsScreen() { // Always emitted, so item 1 exists from the first composition (including while // loading) and the parked position has a stable anchor; empty when there is no row. item(key = "archived", contentType = "archived") { - if (state.showsArchivedRow) { - ArchivedRow( - count = state.projection.archivedRowCount, - onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, - ) + Box(parkFiller.tracked("archived")) { + if (state.showsArchivedRow) { + ArchivedRow( + count = state.projection.archivedRowCount, + onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, + ) + } } } - val visible = state.visibleChats when { // A full-height spacer: with only the chips and an empty Archived slot the content // is shorter than the viewport, the list fills backward to index 0, and the chips // would show once rows arrive. - !chats.isLoaded() -> item(key = "loading") { Spacer(Modifier.fillParentMaxSize()) } - state.hasNoChatsAtAll -> item(key = "empty") { NoChatsYet(Modifier.fillParentMaxSize()) } + !chats.isLoaded() -> item(key = "loading") { + Spacer(parkFiller.tracked("loading").fillParentMaxSize()) + } + state.hasNoChatsAtAll -> item(key = "empty") { + NoChatsYet(parkFiller.tracked("empty").fillParentMaxSize()) + } visible.isEmpty() -> item(key = "empty_filter") { EmptyFilterState( - modifier = Modifier.fillParentMaxSize(), + modifier = parkFiller.tracked("empty_filter").fillParentMaxSize(), message = when (state.filter) { ChatListFilter.Unread -> R.string.title_noUnreadChats ChatListFilter.Groups -> R.string.title_noGroupChats @@ -280,8 +300,14 @@ fun ChatsScreen() { } }, revealGroup = revealGroup, + parkFiller = parkFiller, ) } + + // Makes up a list shorter than the viewport, so it can still park; see [ParkFiller]. + item(key = "park_filler", contentType = "park_filler") { + Spacer(with(parkFiller) { filler(parkKeys) }) + } } } } @@ -340,10 +366,12 @@ private fun LazyListScope.tipChatItems( onMute: (ConversationReference) -> Unit, onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, + parkFiller: ParkFiller, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> ChatSwipeRow( + modifier = parkFiller.tracked(chat.chatId), isMuted = rememberIsMuted(chat.viewerState), onMute = { onMute(chat) }, onArchive = { onArchive(chat) }, @@ -380,6 +408,7 @@ private fun ChatSwipeRow( onArchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val muteLabel = stringResource( @@ -420,7 +449,7 @@ private fun ChatSwipeRow( }, ), // A swipe is out of reach with a screen reader, so the row offers the same actions there. - modifier = Modifier.semantics { + modifier = modifier.semantics { customActions = listOf( CustomAccessibilityAction(muteLabel) { onMute(); true }, CustomAccessibilityAction(archiveLabel) { onArchive(); true }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt index 5cb9397ec3..dcc58f67c1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt @@ -8,7 +8,15 @@ import androidx.compose.foundation.gestures.ScrollScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.PointerEventPass @@ -245,3 +253,59 @@ internal fun LazyListState.chipVisibleFraction(): Float { if (height <= 0f) return 0f return (-pastPark() / height).coerceIn(0f, 1f) } + +/** + * Keeps the list long enough to park. Parked means item 1 at the top, which a list can only hold + * if everything from item 1 on is at least a viewport tall: shorter, and the list scrolls back to + * index 0 and the chips show, on the first frame included. iOS sets the scroll distance outright; + * here a last item makes up the difference, and nothing more, so the list never scrolls further + * past its last row than the chip row's height. + * + * Each item after the chips reports its height with [tracked] as it is measured. The list measures + * forward from item 1, so in a short list every row has reported by the time [filler] is measured + * in the same pass, and the first frame is already parked. + */ +@Stable +internal class ParkFiller { + private val heights = mutableStateMapOf() + + /** Reports the height of the item under [key]. On the root of each item between chips and filler. */ + fun tracked(key: Any): Modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val known = Snapshot.withoutReadObservation { heights[key] } + if (known != placeable.height) heights[key] = placeable.height + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + + /** + * The last item's modifier, from a [LazyItemScope]: as tall as the viewport less the items + * under [keys]. While any of them has not been measured yet the list is long enough already + * (it skipped them), so it takes no height. + */ + fun LazyItemScope.filler(keys: List): Modifier = Modifier + .layout { measurable, constraints -> + // Measured only to learn the viewport (less the content padding). + val viewport = measurable.measure(constraints).height + var taken = 0 + var known = true + for (key in keys) { + val height = heights[key] + if (height == null) { + known = false + break + } + taken += height + } + val height = if (known) (viewport - taken).coerceAtLeast(0) else 0 + layout(constraints.maxWidth.takeIf { it != Constraints.Infinity } ?: 0, height) {} + } + .fillParentMaxHeight() + + /** Forgets items no longer in the list. */ + fun retain(keys: List) { + heights.keys.retainAll(keys.toSet()) + } +} + +@Composable +internal fun rememberParkFiller(): ParkFiller = remember { ParkFiller() } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt index a727a02c49..916049bd39 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.tipping.internal import androidx.activity.ComponentActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -56,10 +57,12 @@ class ChipRevealTest { // xhdpi: 2px per dp. private val chipPx = 100f - private fun setContent() { + private fun setContent(rows: Int = 60) { composeRule.setContent { listState = rememberLazyListState(initialFirstVisibleItemIndex = 1) val fling = ScrollableDefaults.flingBehavior() + val filler = rememberParkFiller() + val keys = remember(rows) { listOf("archived") + (0 until rows).toList() } val connection = remember(listState, fling) { ChipRevealConnection( listState = listState, @@ -85,8 +88,11 @@ class ChipRevealTest { ) if (firstFrame == null) firstFrame = listState.chipVisibleFraction() } - item(key = "archived") {} - items((0 until 60).toList()) { Box(Modifier.fillMaxWidth().height(60.dp)) } + item(key = "archived") { Box(filler.tracked("archived")) } + items((0 until rows).toList(), key = { it }) { + Box(filler.tracked(it).fillMaxWidth().height(60.dp)) + } + item(key = "filler") { Box(with(filler) { filler(keys) }) } } } composeRule.waitForIdle() @@ -252,4 +258,45 @@ class ChipRevealTest { composeRule.waitForIdle() assertTrue(revealed) } + + @Test + fun `a list shorter than the screen still parks, reveals and parks again`() { + // Three rows: 360px against a 1488px viewport (1600 less the bar's 112). + setContent(rows = 3) + assertEquals(0f, firstFrame!!, 0f) + assertEquals(1, listState.firstVisibleItemIndex) + assertEquals(0, listState.firstVisibleItemScrollOffset) + // The filler makes up the viewport and no more: parked is as far as the list goes. + assertFalse(listState.canScrollForward) + + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a short pull on a list shorter than the screen parks`() { + setContent(rows = 3) + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.3f + 16f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + // A swipe up from park goes nowhere: nothing below the last row to scroll into. + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(-300f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + } + + @Test + fun `a long list scrolled to its end leaves no filler`() { + setContent() + // Scrolled through on the way, so every row has reported its height. + runBlocking { repeat(20) { listState.scrollBy(500f) } } + composeRule.waitForIdle() + val info = listState.layoutInfo + val lastRow = info.visibleItemsInfo.first { it.key == 59 } + // The end of the list is the last row: the filler takes nothing. + assertEquals(info.viewportEndOffset - info.afterContentPadding, lastRow.offset + lastRow.size) + } } From 1b9f89d1ac3f65164e2fdd0886df15cb0e540ac5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:49:10 -0400 Subject: [PATCH 19/39] fix(chat): read the main and archived chat lists from one feed state ChatsViewModel combined chatListFeed() and archivedChatListFeed(), two separate maps of the chat state with no dedupe. Archiving the last main chat could emit the new empty main list before the archived list caught up, flashing the no-chats prompt ahead of the Archived row, and every unrelated state change emitted twice. FeedOperations.feedWithArchived() builds both lists from one ChatState read and drops repeats; the Chats screen reads it through chatListFeeds(), first frame included. --- .../app/tipping/internal/ChatsViewModel.kt | 25 +++-- .../internal/ChatsViewModelArchiveTest.kt | 91 +++++++++++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 13 +++ .../com/flipcash/shared/chat/ChatList.kt | 6 ++ .../internal/delegates/FeedSyncDelegate.kt | 18 ++++ .../shared/chat/ChatArchiveFeedTest.kt | 32 ++++++- 6 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index bea020e90f..e371d9c34e 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -11,14 +11,12 @@ import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatListEntry import com.flipcash.shared.chat.ChatListFilter import com.flipcash.shared.chat.ChatListProjection -import com.flipcash.shared.chat.ChatSummary -import com.flipcash.shared.chat.archivedChatListFeed -import com.flipcash.shared.chat.chatListFeed -import com.flipcash.shared.chat.currentArchivedChatListFeed -import com.flipcash.shared.chat.currentChatListFeed +import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.currentChatListFeeds import com.flipcash.shared.chat.projectChatList import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.opencode.model.financial.Token @@ -105,13 +103,12 @@ internal class ChatsViewModel @Inject constructor( val selfId = { userManager.accountId } fun content( - main: List, - archived: List, + feeds: ChatFeeds, tokens: List, senderProfiles: Map?, ): Event.ChatsUpdated { - val mainRows = mapConversations(main, tokens, senderProfiles, selfId(), resources, chatCoordinator) - val archivedRows = mapConversations(archived, tokens, senderProfiles, selfId(), resources, chatCoordinator) + val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator) + val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator) // The projection sees every chat so the chips, the Archived row and the badge number // come from the one function the fixture tests. Hidden chats never reach the feeds, so @@ -133,20 +130,20 @@ internal class ChatsViewModel @Inject constructor( // On a cold launch the feed is usually built before this screen is, so draw it on the first // frame rather than waiting for the collector below to get a turn on the main thread. - chatCoordinator.currentChatListFeed()?.let { main -> + chatCoordinator.currentChatListFeeds()?.let { feeds -> dispatchEvent( content( - main = main, - archived = chatCoordinator.currentArchivedChatListFeed().orEmpty(), + feeds = feeds, tokens = tokenCoordinator.cachedTokens(), senderProfiles = null, ) ) } + // One flow for both lists: two would let a new main list pair with a stale archived one, + // and archiving the last main chat would flash the no-chats prompt before the Archived row. combine( - chatCoordinator.chatListFeed(), - chatCoordinator.archivedChatListFeed(), + chatCoordinator.chatListFeeds(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), ::content, diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt new file mode 100644 index 0000000000..aba5953887 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatSummary +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Archiving the last chat in the main list moves it to the Archived row in one step. The screen + * reads both lists from one feed, so no state between the two shows the no-chats prompt. + * `ChatArchiveFeedTest` pins the feed side: the delegate emits both lists from one state. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatsViewModelArchiveTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chat = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ), + unreadCount = 0, + ) + + @Test + fun `archiving the last main chat never shows the no-chats prompt`() = + runTest(mainCoroutineRule.dispatcher) { + val feeds = MutableStateFlow(ChatFeeds(main = listOf(chat), archived = emptyList())) + val chatCoordinator = mockk(relaxed = true).also { + every { it.feedWithArchived(*anyVararg()) } returns feeds + every { it.currentFeedWithArchived(*anyVararg()) } returns null + every { it.observeSenderProfiles() } returns flowOf(emptyMap()) + } + val tokenCoordinator = mockk(relaxed = true).also { + every { it.tokens } returns flowOf(emptyList()) + every { it.cachedTokens() } returns emptyList() + } + val vm = ChatsViewModel( + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + resources = FakeResourceHelper(R::class.java), + dispatchers = TestDispatchers(testScheduler), + archiveStore = ChatArchiveStore.None, + ) + + val states = mutableListOf() + backgroundScope.launch { vm.stateFlow.collect { states += it } } + advanceUntilIdle() + + feeds.value = ChatFeeds(main = emptyList(), archived = listOf(chat)) + advanceUntilIdle() + + val loaded = states.filter { it.chats.dataOrNull != null } + assertTrue(loaded.isNotEmpty()) + assertTrue(loaded.none { it.hasNoChatsAtAll }, "a loaded state showed the no-chats prompt") + val last = states.last() + assertEquals(emptyList(), last.chats.dataOrNull) + assertTrue(last.showsArchivedRow) + } +} 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 a14bd9e772..ea52ee322f 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 @@ -34,6 +34,9 @@ import kotlinx.coroutines.flow.StateFlow * * Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate]. */ +/** The main and archived lists from one read of the feed. See [FeedOperations.feedWithArchived]. */ +data class ChatFeeds(val main: List, val archived: List) + interface FeedOperations { /** * Reactive list of conversations of any of [chatTypes], sorted by last activity. @@ -61,6 +64,16 @@ interface FeedOperations { /** [currentFeed]'s counterpart for archived chats. */ fun currentArchivedFeed(vararg chatTypes: ChatType): List? + /** + * [feed] and [archivedFeed] read from one state, for a screen that draws both. Collecting the + * two separately can pair a new main list with a stale archived one: archiving the last main + * chat would briefly show both empty. Emits only when either list changes. + */ + fun feedWithArchived(vararg chatTypes: ChatType): Flow + + /** [feedWithArchived]'s current value, or null while it would emit nothing yet. */ + fun currentFeedWithArchived(vararg chatTypes: ChatType): ChatFeeds? + /** * The same conversations as [feed], paged. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt index cec1986fca..ab9e03048e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -25,3 +25,9 @@ fun FeedOperations.archivedChatListFeed(): Flow> = archivedFee /** [FeedOperations.currentArchivedFeed] for the chats the Chats list shows. */ fun FeedOperations.currentArchivedChatListFeed(): List? = currentArchivedFeed(*chatListTypes) + +/** [FeedOperations.feedWithArchived] for the chats the Chats list shows. */ +fun FeedOperations.chatListFeeds(): Flow = feedWithArchived(*chatListTypes) + +/** [FeedOperations.currentFeedWithArchived] for the chats the Chats list shows. */ +fun FeedOperations.currentChatListFeeds(): ChatFeeds? = currentFeedWithArchived(*chatListTypes) 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 495026f60e..2dcde0fed2 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 @@ -23,6 +23,7 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -44,6 +45,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -145,6 +147,22 @@ class FeedSyncDelegate @Inject constructor( override fun currentArchivedFeed(vararg chatTypes: ChatType): List? = summaries(stateHolder.state.value, chatTypes.toSet(), archived = true) + override fun feedWithArchived(vararg chatTypes: ChatType): Flow { + val requested = chatTypes.toSet() + return stateHolder.state + .mapNotNull { state -> feeds(state, requested) } + .distinctUntilChanged() + } + + override fun currentFeedWithArchived(vararg chatTypes: ChatType): ChatFeeds? = + feeds(stateHolder.state.value, chatTypes.toSet()) + + private fun feeds(state: ChatState, requested: Set): ChatFeeds? { + val main = summaries(state, requested, archived = false) ?: return null + val archived = summaries(state, requested, archived = true) ?: return null + return ChatFeeds(main = main, archived = archived) + } + private fun summaries(state: ChatState, requested: Set, archived: Boolean): List? { // Nothing until the list is known: the chat list shows its empty state for an emitted empty // list, so it must not see one that only means "not read yet". Chats on disk are known as diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt index b863fb3014..b998907256 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt @@ -26,15 +26,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Instant /** - * Rule 1: an archived chat leaves the list, every chip and the tab badge together, because they + * An archived chat leaves the list, every chip and the tab badge together, because they * all read the one feed the delegate filters. It appears only in the archived feed. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -164,4 +166,32 @@ class ChatArchiveFeedTest { assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) assertEquals(2, f.delegate.observeUnreadChatListCount().first()) } + @Test + fun `archiving the last main chat moves it in one emission`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + val emissions = mutableListOf() + backgroundScope.launch { f.delegate.feedWithArchived(*listTypes).collect { emissions += it } } + runCurrent() + + f.archived.value = setOf(ChatId(tipHex), ChatId(groupHex)) + runCurrent() + + assertEquals(2, emissions.size, "one emission before, one after: $emissions") + assertTrue(emissions.none { it.main.isEmpty() && it.archived.isEmpty() }) + assertEquals(emptyList(), emissions.last().main) + assertEquals(2, emissions.last().archived.size) + } + + @Test + fun `an unrelated state change does not re-emit the lists`() = runTest { + val f = fixture(emptySet()) + val emissions = mutableListOf() + backgroundScope.launch { f.delegate.feedWithArchived(*listTypes).collect { emissions += it } } + runCurrent() + + f.archived.value = setOf(ChatId("deadbeef")) + runCurrent() + + assertEquals(1, emissions.size) + } } From 2f43f586dbaa14651a5be654ad24b992d7bcc15e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:49:10 -0400 Subject: [PATCH 20/39] fix(notifications): keep a push when archive handling fails If applying archive to a push threw, the whole notification was dropped. Fall back to posting it unchanged and trace the failure. PushMessageClassifier's runCatching around the suspending openPushedChatMessage also swallowed CancellationException; rethrow it. --- .../app/notifications/NotificationService.kt | 16 +++++++++++++++- .../app/notifications/PushMessageClassifier.kt | 16 +++++++++++----- .../notifications/PushMessageClassifierTest.kt | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 1e9aae8bee..5e8bde3782 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -237,7 +237,7 @@ class NotificationService : FirebaseMessagingService(), launch { try { if (syncContacts) launch { contactCoordinator.sync() } - val decided = post?.let { applyArchive(it) } + val decided = post?.let { applyArchiveOrPost(it) } if (decided != null) { val resolvedTitle = applySubstitutions(decided.title, decided.payload?.titleSubstitutions.orEmpty()) @@ -253,6 +253,20 @@ class NotificationService : FirebaseMessagingService(), } } + /** + * [applyArchive], falling back to [post] unchanged if it fails: a broken archive read must not + * drop the notification. An archived chat may then notify loudly, which is the safer miss. + */ + private suspend fun applyArchiveOrPost(post: PushAction.PostNotification): PushAction.PostNotification? = + try { + applyArchive(post) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Couldn't apply archive to a push", error = e) + post + } + /** * Re-decides [post] for an archived chat. Runs after authentication, because the archive and * the viewer's username, account id and stored messages all live in the per-user database and diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt index 9e29f51ae9..b018326ecd 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt @@ -10,10 +10,11 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ui.detectMentions import com.flipcash.shared.chat.ui.detectUrls import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException /** * Whether a received message is addressed to the viewer: the two ways a push breaks through an - * archived chat's silence (rule 3). + * archived chat's silence. An archived chat notifies only for a message addressed to the viewer. */ data class PushClassification( val mentionsViewer: Boolean, @@ -28,7 +29,7 @@ data class PushClassification( /** * Reads a push's message against what this device knows. * - * Both answers default to false when the device cannot tell, which is rule 3's "stays silent": + * Both answers default to false when the device cannot tell, so an archived chat stays silent: * - the message is not in the payload and not yet stored, or has no readable text (cash, media * without a caption), so there is nothing to scan; * - the message is end-to-end encrypted and cannot be opened on this device; @@ -53,9 +54,14 @@ class PushMessageClassifier @Inject constructor( ?: metadata?.messageId?.let { messages.getMessage(chatId, it) } ?: return PushClassification.None val message = if (received.content.singleOrNull() is MessageContent.Encrypted) { - runCatching { chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) } - .getOrNull() - ?: return PushClassification.None + val opened = try { + chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + opened ?: return PushClassification.None } else { received } diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt index ae7eeb3a77..44c25f7bcb 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt @@ -23,7 +23,7 @@ import kotlin.test.assertEquals import kotlin.time.Instant /** - * Rule 3's two ways in, and the default when the device cannot tell. Runs under Robolectric because + * The two ways a message breaks through an archived chat's silence, and the default when the device cannot tell. Runs under Robolectric because * mention detection reads `android.util.Patterns` for the links it must skip. */ @RunWith(RobolectricTestRunner::class) From 1817f2a87b6644e166964e034d4da61c1597344f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:49:10 -0400 Subject: [PATCH 21/39] docs(chat): state the archive rules instead of citing rule numbers Comments cited rules 1, 2, 3 and 5, which nothing in the repo defines. Also correct ChatListEntry.id (the app passes the base58 chat id) and note that tabBadge is read only by the shared fixture tests on Android. --- .../com/flipcash/shared/chat/ArchiveNotifyRule.kt | 4 ++-- .../com/flipcash/shared/chat/ChatListProjection.kt | 11 +++++++---- .../shared/chat/internal/RealChatCoordinator.kt | 2 +- .../chat/internal/delegates/FeedSyncDelegate.kt | 4 ++-- .../com/flipcash/shared/chat/ChatArchiveVectorTest.kt | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt index ddd5834cc1..54e0227547 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt @@ -4,11 +4,11 @@ package com.flipcash.shared.chat * Whether a push for a chat may post a notification. * * - **Muted: never**, archived or not. Mute already suppresses every push and archiving must not - * add a way through it (rule 2). + * add a way through it. * - **Not archived and not muted: always.** Archive adds a filter; it never changes what an * ordinary chat does. * - **Archived, not muted: only a message addressed to the viewer**, which is an @mention of them - * or a reply to one of their messages (rule 3). The message's kind does not matter: received + * or a reply to one of their messages. The message's kind does not matter: received * cash, media and system messages do not break through on their own, because in a tip DM most * messages may be cash and letting them through would make archive useless where people most * want quiet. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt index 999b32c76b..6abf462251 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt @@ -4,8 +4,8 @@ package com.flipcash.shared.chat enum class ChatListFilter { All, Unread, Groups } /** - * One chat as the list projection sees it. [id] is opaque here (the app passes the chat id hex; - * the fixture passes its own ids). [unread] is the chat's unread count, with null meaning "unread + * One chat as the list projection sees it. [id] is opaque here (the app passes the chat id's + * `toString()`, its base58 form; the fixture passes its own ids). [unread] is the chat's unread count, with null meaning "unread * by an unknown count", which counts as unread. */ data class ChatListEntry( @@ -36,7 +36,9 @@ data class ChatListEntry( * @property archivedRowCount unread archived chats that are not muted. Muted archived chats count * toward nothing, as muted chats count toward nothing there today. * @property tabBadge unread chats in [main], muted ones included, as the Chats tab badge counts - * today. + * today. No Android screen reads it: the badge comes from + * [observeUnreadChatListCount], which counts the same chats. It stays because the shared + * fixture's `list` cases check it on both platforms, and iOS draws its badge from it. */ data class ChatListProjection( val main: List, @@ -71,7 +73,8 @@ data class ChatListProjection( } /** - * Rule 1 and the numbers the list draws. Pure, so the fixture's `list` cases drive it directly. + * Splits chats into the main list and the Archived row (an archived chat leaves the list, every + * chip and the tab badge together), and counts the numbers the list draws. Pure, so the fixture's `list` cases drive it directly. * * Ties in last activity keep their input order (`sortedByDescending` is stable). */ diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 0717b09835..548759371d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -315,7 +315,7 @@ class RealChatCoordinator @Inject constructor( override suspend fun leave(chatId: ChatId): Result = groupFeedDelegate.leave(chatId).onSuccess { draftStore.clear(chatId) - // Leaving clears archive the way it clears mute (rule 5). A remote leave, from another + // Leaving a group clears its archive the way it clears mute. A remote leave, from another // device, arrives through GroupFeedDelegate's roster path, which has no store: the // record is left behind, harmlessly, because the chat is no longer a member chat and // so is in neither feed. 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 2dcde0fed2..c7a700f43b 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 @@ -177,8 +177,8 @@ class FeedSyncDelegate @Inject constructor( val selfPhone = userManager.profile?.verifiedPhoneNumber return feed .filter { it.type in requested } - // The one place archive is applied (rule 1): the list, every chip and the tab badge read - // `feed`, so none of them can disagree about what is archived. + // The one place archive is applied. An archived chat leaves the list, every chip and the + // tab badge together: they all read `feed`, so none can disagree about what is archived. .filter { (it.chatId in state.archived) == archived } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt index 5d9c394e8a..97f6fc40b1 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt @@ -40,7 +40,7 @@ class ChatArchiveVectorTest { repliesToViewer = push.triState("repliesToViewer"), ) assertEquals(expect.getBoolean("notify"), actual, "$name: ${case.optString("note")}") - // Rule 3: nothing a message does unarchives the chat. No Android code path can write + // Nothing a message does unarchives the chat. No Android code path can write // archive from a push, so the fixture's `expect.archived` is checked against the input. assertEquals(push.getBoolean("archived"), expect.getBoolean("archived"), "$name: a push never changes archive") } From 776392d49a39bd472df7bea9995cdf4567329b86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 18:55:16 -0400 Subject: [PATCH 22/39] style(chat): keep the FeedOperations KDoc on its interface --- .../main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt | 6 +++--- .../kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) 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 ea52ee322f..0e4a99dd45 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 @@ -29,14 +29,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +/** The main and archived lists from one read of the feed. See [FeedOperations.feedWithArchived]. */ +data class ChatFeeds(val main: List, val archived: List) + /** * Feed-level operations: observing the conversation list and its unread state. * * Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate]. */ -/** The main and archived lists from one read of the feed. See [FeedOperations.feedWithArchived]. */ -data class ChatFeeds(val main: List, val archived: List) - interface FeedOperations { /** * Reactive list of conversations of any of [chatTypes], sorted by last activity. diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt index b998907256..79c8d7b0f9 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt @@ -166,6 +166,7 @@ class ChatArchiveFeedTest { assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) assertEquals(2, f.delegate.observeUnreadChatListCount().first()) } + @Test fun `archiving the last main chat moves it in one emission`() = runTest { val f = fixture(setOf(ChatId(groupHex))) From 6ad182120aa05f05c9498b2a8a66da93e4af8b46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 19:36:00 -0400 Subject: [PATCH 23/39] fix(ui): draw the snackbar action bold in the message colour --- .../com/getcode/ui/theme/CodeSnackbar.kt | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt index bf6cd72e8f..d8b3d7a972 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt @@ -1,17 +1,24 @@ package com.getcode.ui.theme +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Snackbar import androidx.compose.material.SnackbarData +import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.theme.BrandMuted import com.getcode.theme.CodeTheme -import com.getcode.theme.Success +/** + * The action label is bold and, by default, the same colour as the message, matching the iOS + * archive undo toast. + */ @Composable fun CodeSnackbar( snackbarData: SnackbarData, @@ -20,17 +27,28 @@ fun CodeSnackbar( shape: Shape = CodeTheme.shapes.small, backgroundColor: Color = BrandMuted, contentColor: Color = CodeTheme.colors.onBackground, - actionColor: Color = Success, + actionColor: Color = contentColor, elevation: Dp = 6.dp ) { + val actionLabel = snackbarData.actionLabel Snackbar( - snackbarData = snackbarData, modifier = modifier, + action = actionLabel?.let { + { + TextButton( + onClick = { snackbarData.performAction() }, + colors = ButtonDefaults.textButtonColors(contentColor = actionColor), + ) { + Text(text = it, fontWeight = FontWeight.Bold) + } + } + }, actionOnNewLine = actionOnNewLine, shape = shape, backgroundColor = backgroundColor, contentColor = contentColor, - actionColor = actionColor, - elevation = elevation - ) -} \ No newline at end of file + elevation = elevation, + ) { + Text(snackbarData.message) + } +} From cd3c50fbfbc97dae456959a281c2eed37694b456 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:32:44 -0400 Subject: [PATCH 24/39] feat(chat): animate chat rows leaving and returning in the chat lists Archiving a chat fades its row out and slides the rows below into the gap; Undo fades it back in. Filter changes animate the same way, and the empty states fade so their message doesn't land on rows still fading out. The Archived row fades inside its always-present slot. The Archived chats screen animates an unarchived row the same way and keeps its empty state in the list, so the last row fades out rather than the list being swapped for the message. --- .../app/tipping/ArchivedChatsScreen.kt | 67 +++++++++++-------- .../com/flipcash/app/tipping/ChatsScreen.kt | 27 ++++++-- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt index 6f2c8bd9d2..24bb54641c 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -78,35 +78,43 @@ private fun ArchivedChatsContent( ) }, ) { padding -> - if (isLoaded && chats.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.title_noArchivedChats), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().testTag("archived_list"), - contentPadding = PaddingValues(top = padding.calculateTopPadding()), - ) { - // Keyed by chat so a row's swipe state stays with its chat as activity reorders. - itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> - UnarchiveSwipeRow( - onUnarchive = { onUnarchive(chat.chatId) }, - stateKey = chat.chatId, - revealGroup = revealGroup, + // One list for the rows and the empty state alike, so unarchiving the last chat fades its + // row out and the message in, rather than swapping the whole list for the message at once. + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("archived_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + if (isLoaded && chats.isEmpty()) { + item(key = "empty") { + Box( + modifier = Modifier + .animateItem() + .fillParentMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + contentAlignment = Alignment.Center, ) { - // The row draws its divider below itself, so none sits above the first row. - TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { - onOpen(chat.chatId) - } + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.title_noArchivedChats), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + // Keyed by chat so a row's swipe state stays with its chat as activity reorders. + // animateItem fades an unarchived row out where it stands and slides the rest up. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + UnarchiveSwipeRow( + modifier = Modifier.animateItem(), + onUnarchive = { onUnarchive(chat.chatId) }, + stateKey = chat.chatId, + revealGroup = revealGroup, + ) { + // The row draws its divider below itself, so none sits above the first row. + TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { + onOpen(chat.chatId) } } } @@ -119,6 +127,7 @@ private fun UnarchiveSwipeRow( onUnarchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val label = stringResource(R.string.content_description_unarchiveChat) @@ -137,7 +146,7 @@ private fun UnarchiveSwipeRow( }, ), // A swipe is out of reach with a screen reader, so the row offers the action there. - modifier = Modifier.semantics { + modifier = modifier.semantics { customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) }, stateKey = stateKey, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index e4d98f9676..4fa1c390e4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tipping +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -248,9 +251,15 @@ fun ChatsScreen() { // Always emitted, so item 1 exists from the first composition (including while // loading) and the parked position has a stable anchor; empty when there is no row. + // Its key never leaves the list, so animateItem only moves it; the row itself fades in + // and out inside the slot, while the rows below slide to make room or close the gap. item(key = "archived", contentType = "archived") { - Box(parkFiller.tracked("archived")) { - if (state.showsArchivedRow) { + Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { + AnimatedVisibility( + visible = state.showsArchivedRow, + enter = fadeIn(), + exit = fadeOut(), + ) { ArchivedRow( count = state.projection.archivedRowCount, onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, @@ -266,12 +275,18 @@ fun ChatsScreen() { !chats.isLoaded() -> item(key = "loading") { Spacer(parkFiller.tracked("loading").fillParentMaxSize()) } + // The empty states fade like the rows do, so a filter or archive that empties the + // list doesn't drop the message on top of rows still fading out. state.hasNoChatsAtAll -> item(key = "empty") { - NoChatsYet(parkFiller.tracked("empty").fillParentMaxSize()) + NoChatsYet( + Modifier.animateItem().then(parkFiller.tracked("empty")).fillParentMaxSize() + ) } visible.isEmpty() -> item(key = "empty_filter") { EmptyFilterState( - modifier = parkFiller.tracked("empty_filter").fillParentMaxSize(), + modifier = Modifier.animateItem() + .then(parkFiller.tracked("empty_filter")) + .fillParentMaxSize(), message = when (state.filter) { ChatListFilter.Unread -> R.string.title_noUnreadChats ChatListFilter.Groups -> R.string.title_noGroupChats @@ -369,9 +384,11 @@ private fun LazyListScope.tipChatItems( parkFiller: ParkFiller, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. + // animateItem fades a row out where it stands when it is archived (or filtered away), even + // swiped open, slides its neighbours into the gap, and fades it back in on Undo. itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> ChatSwipeRow( - modifier = parkFiller.tracked(chat.chatId), + modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), isMuted = rememberIsMuted(chat.viewerState), onMute = { onMute(chat) }, onArchive = { onArchive(chat) }, From e8b32d755599132fcbd6757cf7b01ce1eec7743a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:44:56 -0400 Subject: [PATCH 25/39] fix(ui): mark the selected filter chip with a white tint A solid white selected chip was the brightest thing on the dark chat list. Selected is now white at 16% with the label in the main text colour; unselected has no fill and uses the secondary text colour. Both colours animate on change. --- .../com/getcode/ui/components/FilterChip.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt index 577aa4a96c..d792699cde 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt @@ -1,23 +1,30 @@ package com.getcode.ui.components +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme +import com.getcode.theme.White /** * A chip that can be selected, for choosing one filter among a few. [CodeChip] is a label with no * click or selected state, so this wraps it rather than changing what every other chip means. * * Selection is exposed to accessibility (`Role.RadioButton`, selected), because the colour change - * is the only visual cue. A [count] of null or 0 draws nothing: the chips show a number only when - * there is something in the filter. + * is the only visual cue. Selected is a white tint at 16% over whatever is behind the chip, with the + * label in the main text colour; unselected has no fill and uses the secondary text colour. A + * solid white chip was the brightest thing on a dark screen. A [count] of null or 0 draws nothing: + * the chips show a number only when there is something in the filter. */ @Composable fun FilterChip( @@ -27,12 +34,21 @@ fun FilterChip( modifier: Modifier = Modifier, count: Int? = null, ) { - val contentColor = if (selected) CodeTheme.colors.background else CodeTheme.colors.textMain + val backgroundColor by animateColorAsState( + targetValue = if (selected) SelectedChipTint else Color.Transparent, + label = "filterChipBackground", + ) + val contentColor by animateColorAsState( + targetValue = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary, + label = "filterChipContent", + ) CodeChip( modifier = modifier .clip(CircleShape) + // Drawn rather than passed as CodeChip's background so the animated fill only redraws. + .drawBehind { drawRect(backgroundColor) } .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), - backgroundColor = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.surfaceVariant, + backgroundColor = Color.Transparent, contentPadding = PaddingValues( horizontal = CodeTheme.dimens.grid.x3, // 7dp to match iOS's selectable chip; no grid step lands on it. @@ -53,3 +69,6 @@ fun FilterChip( } } } + +// No theme token sits at 16%; White10 and White20 bracket it. +private val SelectedChipTint = White.copy(alpha = 0.16f) From b376d98ef4388fd6ca4f6d7896acefffb2d55b87 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:48:36 -0400 Subject: [PATCH 26/39] feat(ui): draw snackbars as the shared floating pill CodeSnackbar was a full-width Material snackbar while iOS floated a capsule. It is now the pill both platforms share: surfaceVariant fill with a 0.5dp white 10% border, an optional 18dp icon, the message, and the action in its own white 12% pill. CodeSnackbarHost replaces Material's SnackbarHost, whose fade and scale cannot be turned off. It slides the toast up and fades it in, leaves the same way, and dismisses on a swipe down. A short toast stays 4 seconds, extended by the accessibility timeout. showSnackbar(SnackData) now replaces the toast on screen instead of queueing. The chat list passes the archive icon to its undo toast. --- .../com/flipcash/app/tipping/ChatsScreen.kt | 8 +- .../getcode/ui/components/snack/SnackData.kt | 4 + .../com/getcode/ui/theme/CodeScaffold.kt | 5 +- .../com/getcode/ui/theme/CodeSnackbar.kt | 213 +++++++++++++++--- 4 files changed, 192 insertions(+), 38 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 4fa1c390e4..507e9e91c4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Archive @@ -39,6 +38,7 @@ import com.getcode.ui.components.FilterChip import com.getcode.ui.components.snack.SnackData import com.getcode.ui.components.snack.showSnackbar import com.getcode.ui.theme.CodeSnackbar +import com.getcode.ui.theme.CodeSnackbarHost import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -194,8 +194,9 @@ fun ChatsScreen() { }, // The tab bar is hoisted, so without this the snackbar draws underneath it. snackbarHost = { hostState -> - SnackbarHost(hostState, modifier = Modifier.padding(bottom = tabBarPadding)) { data -> - CodeSnackbar(snackbarData = data) + CodeSnackbarHost(hostState, modifier = Modifier.padding(bottom = tabBarPadding)) { data -> + // Every toast on this screen is the archive undo. + CodeSnackbar(snackbarData = data, icon = Icons.Outlined.Archive) } }, ) { barPadding -> @@ -309,7 +310,6 @@ fun ChatsScreen() { // The row leaves the list at once, so offer to put it back. scope.launch { val host = scaffoldState.snackbarHostState - host.currentSnackbarData?.dismiss() val result = host.showSnackbar(SnackData(message = archivedMessage, actionLabel = undoLabel)) if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt index 1f7f1e4f63..8cd40bb55e 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt @@ -10,7 +10,11 @@ data class SnackData( val duration: SnackbarDuration = SnackbarDuration.Short ) +/** + * Shows [data], replacing any snackbar already on screen rather than queueing behind it. + */ suspend fun SnackbarHostState.showSnackbar(data: SnackData): SnackbarResult { + currentSnackbarData?.dismiss() return showSnackbar( message = data.message, actionLabel = data.actionLabel, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index dcc228544f..fc5992dfef 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -6,7 +6,6 @@ import androidx.compose.material.DrawerDefaults import androidx.compose.material.FabPosition import androidx.compose.material.Scaffold import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -39,9 +38,7 @@ fun CodeScaffold( topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, snackbarHost: @Composable (SnackbarHostState) -> Unit = { - SnackbarHost(it) { data -> - CodeSnackbar(snackbarData = data) - } + CodeSnackbarHost(it) }, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt index d8b3d7a972..c8bd26b1fa 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt @@ -1,54 +1,207 @@ package com.getcode.ui.theme -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Snackbar +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.material.Icon import androidx.compose.material.SnackbarData +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalAccessibilityManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.getcode.theme.BrandMuted import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White10 +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** - * The action label is bold and, by default, the same colour as the message, matching the iOS - * archive undo toast. + * The app's toast: a pill one step lighter than the screen, with a hairline border, holding an + * optional [icon], the message, and the action in its own pill. iOS draws the same design. + * + * Show it through [CodeSnackbarHost], which owns its motion, lifetime and swipe to dismiss. */ @Composable fun CodeSnackbar( snackbarData: SnackbarData, modifier: Modifier = Modifier, - actionOnNewLine: Boolean = false, - shape: Shape = CodeTheme.shapes.small, - backgroundColor: Color = BrandMuted, - contentColor: Color = CodeTheme.colors.onBackground, - actionColor: Color = contentColor, - elevation: Dp = 6.dp + icon: ImageVector? = null, ) { val actionLabel = snackbarData.actionLabel - Snackbar( + Row( + modifier = modifier + .fillMaxWidth() + .padding(12.dp) + .clip(CircleShape) + // The nearest token to one step above the screen background (Brand). + .background(CodeTheme.colors.surfaceVariant) + .border(0.5.dp, White10, CircleShape) + .padding( + PaddingValues( + start = 16.dp, + top = 8.dp, + bottom = 8.dp, + end = if (actionLabel != null) 8.dp else 16.dp, + ) + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + Text( + text = snackbarData.message, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + modifier = Modifier + .weight(1f) + // The action pill's vertical padding, so a toast is the same height with or without one. + .padding(vertical = 7.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (actionLabel != null) { + Text( + text = actionLabel, + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Bold, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(CircleShape) + .background(ActionFill) + .clickable(role = Role.Button) { snackbarData.performAction() } + .padding(horizontal = 16.dp, vertical = 7.dp), + ) + } + } +} + +/** + * Shows [hostState]'s current snackbar with [snackbar], one at a time. It slides up and fades in, + * leaves the same way, and a swipe down dismisses it. A [SnackbarDuration.Short] snackbar stays 4 + * seconds, or longer when the accessibility settings ask for more time. + * + * Material's `SnackbarHost` is not used because its fade and scale cannot be turned off and + * would play over the slide. + */ +@Composable +fun CodeSnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, + snackbar: @Composable (SnackbarData) -> Unit = { CodeSnackbar(it) }, +) { + val current = hostState.currentSnackbarData + val accessibilityManager = LocalAccessibilityManager.current + LaunchedEffect(current) { + if (current == null) return@LaunchedEffect + val millis = when (current.duration) { + SnackbarDuration.Short -> 4_000L + SnackbarDuration.Long -> 10_000L + SnackbarDuration.Indefinite -> return@LaunchedEffect + } + val timeout = accessibilityManager?.calculateRecommendedTimeoutMillis( + originalTimeoutMillis = millis, + containsIcons = true, + containsText = true, + containsControls = current.actionLabel != null, + ) ?: millis + delay(timeout) + current.dismiss() + } + + AnimatedContent( + targetState = current, modifier = modifier, - action = actionLabel?.let { - { - TextButton( - onClick = { snackbarData.performAction() }, - colors = ButtonDefaults.textButtonColors(contentColor = actionColor), - ) { - Text(text = it, fontWeight = FontWeight.Bold) + transitionSpec = { + (slideInVertically { it } + fadeIn()) togetherWith + (slideOutVertically { it } + fadeOut()) using + SizeTransform(clip = false) + }, + contentAlignment = Alignment.BottomCenter, + label = "snackbar", + ) { data -> + if (data != null) { + SwipeToDismiss(data) { snackbar(data) } + } else { + // An empty slot, so the outgoing snackbar can animate away. + Box(Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun SwipeToDismiss(data: SnackbarData, content: @Composable () -> Unit) { + val offset = remember(data) { Animatable(0f) } + val scope = rememberCoroutineScope() + val threshold = with(LocalDensity.current) { 32.dp.toPx() } + Box( + modifier = Modifier + .graphicsLayer { translationY = offset.value } + .semantics { + dismiss { + data.dismiss() + true } } - }, - actionOnNewLine = actionOnNewLine, - shape = shape, - backgroundColor = backgroundColor, - contentColor = contentColor, - elevation = elevation, + .pointerInput(data) { + detectVerticalDragGestures( + onDragEnd = { + if (offset.value > threshold) { + data.dismiss() + } else { + scope.launch { offset.animateTo(0f) } + } + }, + onDragCancel = { scope.launch { offset.animateTo(0f) } }, + ) { change, dragAmount -> + change.consume() + scope.launch { offset.snapTo((offset.value + dragAmount).coerceAtLeast(0f)) } + } + }, ) { - Text(snackbarData.message) + content() } } + +// No theme token sits at 12%; White10 is the nearest below it. +private val ActionFill = White.copy(alpha = 0.12f) From 1b68a816adfc43705e972a41585594388b72cf46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 20:52:23 -0400 Subject: [PATCH 27/39] fix(ui): give the snackbar action a 48dp touch area and set the message in regular weight --- .../com/getcode/ui/theme/CodeSnackbar.kt | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt index c8bd26b1fa..49fb1a42e5 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt @@ -12,11 +12,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -25,6 +28,7 @@ import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text +import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -74,8 +78,9 @@ fun CodeSnackbar( .padding( PaddingValues( start = 16.dp, - top = 8.dp, - bottom = 8.dp, + // With an action, its 48dp touch area sets the height instead. + top = if (actionLabel != null) 0.dp else 8.dp, + bottom = if (actionLabel != null) 0.dp else 8.dp, end = if (actionLabel != null) 8.dp else 16.dp, ) ), @@ -93,6 +98,7 @@ fun CodeSnackbar( Text( text = snackbarData.message, style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Normal, color = CodeTheme.colors.textMain, modifier = Modifier .weight(1f) @@ -101,17 +107,30 @@ fun CodeSnackbar( .semantics { liveRegion = LiveRegionMode.Polite }, ) if (actionLabel != null) { - Text( - text = actionLabel, - style = CodeTheme.typography.textSmall, - fontWeight = FontWeight.Bold, - color = CodeTheme.colors.textMain, + val interactionSource = remember { MutableInteractionSource() } + // The touch area is the toast's full height; the ripple stays on the visible pill. + Box( modifier = Modifier - .clip(CircleShape) - .background(ActionFill) - .clickable(role = Role.Button) { snackbarData.performAction() } - .padding(horizontal = 16.dp, vertical = 7.dp), - ) + .heightIn(min = 48.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + ) { snackbarData.performAction() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = actionLabel, + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Bold, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(CircleShape) + .background(ActionFill) + .indication(interactionSource, ripple()) + .padding(horizontal = 16.dp, vertical = 7.dp), + ) + } } } } From 3d4a8c651fd7e169cb494fd00b46d1a7a6f6d8a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:08:35 -0400 Subject: [PATCH 28/39] fix(ui): outline both filter chip states in white at 10% --- .../src/main/kotlin/com/getcode/ui/components/FilterChip.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt index d792699cde..ee51dff908 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt @@ -1,6 +1,7 @@ package com.getcode.ui.components import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.border import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape @@ -15,6 +16,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.theme.White +import com.getcode.theme.White10 /** * A chip that can be selected, for choosing one filter among a few. [CodeChip] is a label with no @@ -22,7 +24,8 @@ import com.getcode.theme.White * * Selection is exposed to accessibility (`Role.RadioButton`, selected), because the colour change * is the only visual cue. Selected is a white tint at 16% over whatever is behind the chip, with the - * label in the main text colour; unselected has no fill and uses the secondary text colour. A + * label in the main text colour; unselected has no fill and uses the secondary text colour. Both + * carry a 1dp white outline at 10%, as iOS draws them. A * solid white chip was the brightest thing on a dark screen. A [count] of null or 0 draws nothing: * the chips show a number only when there is something in the filter. */ @@ -47,6 +50,7 @@ fun FilterChip( .clip(CircleShape) // Drawn rather than passed as CodeChip's background so the animated fill only redraws. .drawBehind { drawRect(backgroundColor) } + .border(1.dp, White10, CircleShape) .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), backgroundColor = Color.Transparent, contentPadding = PaddingValues( From 709695cabc66f4c8327e57c0b5ff3191dab4118b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:12:37 -0400 Subject: [PATCH 29/39] refactor(ui): share the navigation bar's glass and inset as floating chrome --- .../app/internal/ui/AppNavigationBar.kt | 6 +- .../com/flipcash/app/core/ui/NavigationBar.kt | 40 ++--------- .../ui/components/glass/FloatingGlass.kt | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+), 38 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 096c02e30b..a250a03088 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -35,6 +35,7 @@ import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.ui.components.glass.FloatingChrome import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -127,9 +128,8 @@ internal fun AppNavigationBar( NavigationBar( modifier = Modifier .navigationBarsPadding() - // The design insets the bar 24pt from each edge (node 10642:1325), which - // keeps each tab's pill wider than tall; 25 is the nearest fixed step. - .padding(horizontal = CodeTheme.dimens.staticGrid.x5) + // Shared with the toast that rises out of the bar, so it is never wider. + .padding(horizontal = FloatingChrome.horizontalInset) .padding(bottom = CodeTheme.dimens.grid.x3), state = state, onButtonClick = { button -> diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 42448c39b7..e55d15636b 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag @@ -51,15 +50,12 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.flipcash.app.core.navigation.NavBarButton -import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.hazeBlur import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge +import com.getcode.ui.components.glass.floatingGlass data class NavigationBarState( // Route-driven: the caller derives this from the current backstack tab so the highlighted tab @@ -127,36 +123,10 @@ fun NavigationBar( .takeIf { it >= 0 && it <= order.lastIndex } ?: order.indexOf(NavBarButton.Wallet) - // Frost the pill over whatever content scrolls beneath it, iOS "liquid glass" style: a wide blur - // plus a strong tint toward the BACKGROUND colour (not black) at high alpha. Over empty/dark - // content the pill just reads as the background (a subtle glass, not a black blob); over the - // vibrant cards the high alpha mutes their colour toward that same neutral dark. A faint bright - // rim gives the glass edge. Haze can only blur Compose-layer pixels, so on the scanner tab (a - // camera SurfaceView) fall back to the opaque pill. `clip` must precede `hazeBlur` to bound the - // blur to the pill shape, not its bounding box. - // Tint toward a grey lifted off the (near-black) background so the pill reads as a light frosted - // glass sitting ABOVE the dark content, not the background tone itself. - val backdrop = CodeTheme.colors.background - val glassTint = lerp(backdrop, Color.White, 0.18f) - // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. - val liquidGlass = HazeBlurStyle { - blurRadius(32.dp) - backgroundColor(backdrop) - colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) - } - // Same clip + rim on every tab; only the fill differs. Haze frosts the content beneath — including - // the scanner's live camera, since its PreviewView runs in COMPATIBLE mode (a TextureView drawn in - // the Compose layer, not a SurfaceView hole). Fall back to a near-opaque fill of the same - // lifted-grey tint only when no HazeState is supplied. - val pillFill = if (hazeState != null) { - Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) - } else { - Modifier.background(glassTint.copy(alpha = 0.9f), CircleShape) - } - val pillBackground = Modifier - .clip(CircleShape) - .then(pillFill) - .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), CircleShape) + // The shared floating glass. Haze frosts the content beneath, including the scanner's live + // camera, since its PreviewView runs in COMPATIBLE mode (a TextureView drawn in the Compose layer, + // not a SurfaceView hole). With no HazeState it falls back to a near-opaque fill. + val pillBackground = Modifier.floatingGlass(hazeState) BoxWithConstraints( modifier = Modifier diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt new file mode 100644 index 0000000000..327e581d3e --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt @@ -0,0 +1,69 @@ +package com.getcode.ui.components.glass + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur + +/** + * Layout shared by the chrome that floats over the bottom of the screen: the navigation bar and the + * toast that rises out of it. + */ +object FloatingChrome { + /** + * The inset from each screen edge. The design insets the bar 24pt (node 10642:1325), which keeps + * each tab's pill wider than tall; 25 is the nearest fixed step. A toast takes the same inset, so + * it is never wider than the bar. + */ + val horizontalInset: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x5 +} + +/** + * The frosted "liquid glass" surface of the floating chrome, clipped to [shape]. + * + * A wide blur of what lies beneath, plus a strong tint toward a grey lifted off the (near-black) + * background at high alpha. Over empty or dark content it reads as a light frosted glass sitting + * above the screen; over the vibrant cards the high alpha mutes their colour toward that same grey. + * A faint bright rim gives the glass its edge. + * + * [hazeState] must belong to a `hazeSource` that does not contain this surface, or there is nothing + * to blur. With no [hazeState] the surface is a near-opaque fill of the same tint. + */ +@Composable +fun Modifier.floatingGlass( + hazeState: HazeState?, + shape: Shape = CircleShape, +): Modifier { + val backdrop = CodeTheme.colors.background + val glassTint = lerp(backdrop, Color.White, 0.18f) + val fill = if (hazeState != null) { + // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. + val liquidGlass = HazeBlurStyle { + blurRadius(32.dp) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) + } + Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) + } else { + Modifier.background(glassTint.copy(alpha = 0.9f), shape) + } + // `clip` must precede `hazeBlur` to bound the blur to the shape, not its bounding box. + return this + .clip(shape) + .then(fill) + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) +} From 94a04a0fb012287736b645248f64a3289550a986 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:14:00 -0400 Subject: [PATCH 30/39] feat(ui): add FloatingToast on the navigation bar's glass and inset Snackbars now draw through it: the bar's glass instead of the surfaceVariant pill and hairline, an 18dp leading icon in the main text colour, and the bar's horizontal inset instead of 12dp. FloatingToastHost adds a host that can rise out of the bar, hug its message, and pass taps through. --- .../ui/components/toast/FloatingToast.kt | 126 ++++++++ .../ui/components/toast/FloatingToastHost.kt | 281 ++++++++++++++++++ .../com/getcode/ui/theme/CodeSnackbar.kt | 109 +------ 3 files changed, 422 insertions(+), 94 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt new file mode 100644 index 0000000000..6607464a2a --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt @@ -0,0 +1,126 @@ +package com.getcode.ui.components.toast + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.HazeState + +/** + * The app's toast: a pill on the navigation bar's glass, holding an optional [icon], the [message], + * and an optional action in its own pill. iOS draws the same design. + * + * It fills the width it is given unless [hugContent], when it wraps the message instead. Give it + * [FloatingChrome.horizontalInset][com.getcode.ui.components.glass.FloatingChrome.horizontalInset] + * on each side so it is never wider than the bar. [hazeState] is the bar's; without one the glass + * falls back to the bar's near-opaque fill. + * + * With no [actionLabel] nothing here takes pointer input, so taps pass through to what is beneath. + * Show it through [FloatingToastHost], which owns its motion, lifetime and swipe to dismiss. + */ +@Composable +fun FloatingToast( + message: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + actionLabel: String? = null, + onAction: () -> Unit = {}, + hugContent: Boolean = false, + hazeState: HazeState? = null, +) { + Row( + modifier = modifier + .then(if (hugContent) Modifier else Modifier.fillMaxWidth()) + .floatingGlass(hazeState) + .padding( + PaddingValues( + start = 16.dp, + // With an action, its 48dp touch area sets the height instead. + top = if (actionLabel != null) 0.dp else 8.dp, + bottom = if (actionLabel != null) 0.dp else 8.dp, + end = if (actionLabel != null) 8.dp else 16.dp, + ) + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(18.dp), + ) + } + Text( + text = message, + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Normal, + color = CodeTheme.colors.textMain, + modifier = Modifier + // Weighted children measure after the fixed ones, so a long message wraps rather + // than squeezing the action; without fill, a hugging toast stays the message's width. + .weight(1f, fill = !hugContent) + // The action pill's vertical padding, so a toast is the same height with or without one. + .padding(vertical = 7.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (actionLabel != null) { + val interactionSource = remember { MutableInteractionSource() } + // The touch area is the toast's full height; the ripple stays on the visible pill. + Box( + modifier = Modifier + .heightIn(min = 48.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onAction, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = actionLabel, + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Bold, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(CircleShape) + .background(ActionFill) + .indication(interactionSource, ripple()) + .padding(horizontal = 16.dp, vertical = 7.dp), + ) + } + } + } +} + +// No theme token sits at 12%; White10 is the nearest below it. +private val ActionFill = White.copy(alpha = 0.12f) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt new file mode 100644 index 0000000000..fa20744bd5 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt @@ -0,0 +1,281 @@ +package com.getcode.ui.components.toast + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalAccessibilityManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.getcode.ui.components.glass.FloatingChrome +import dev.chrisbanes.haze.HazeState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** One toast on screen in a [FloatingToastHost]. */ +@Immutable +class FloatingToastData internal constructor( + val message: String, + val icon: ImageVector?, + val actionLabel: String?, + val hugContent: Boolean, + val passThrough: Boolean, + val duration: SnackbarDuration, + // Toasts that share a slot swap in place rather than replaying the entrance. + internal val slot: Long, + private val result: CompletableDeferred, +) { + fun performAction() { + result.complete(SnackbarResult.ActionPerformed) + } + + fun dismiss() { + result.complete(SnackbarResult.Dismissed) + } + + internal suspend fun await(): SnackbarResult = result.await() +} + +/** + * The toasts shown by a [FloatingToastHost], one at a time. A new toast replaces the one on screen + * rather than queueing behind it, and the replaced toast's [show] returns + * [SnackbarResult.Dismissed]. + */ +@Stable +class FloatingToastHostState { + var current: FloatingToastData? by mutableStateOf(null) + private set + + private var nextSlot = 0L + + /** + * Shows [message] until it times out, is dismissed or replaced, or its action is performed, and + * returns which. Cancelling the caller takes the toast down. + * + * @param hugContent wrap the message rather than fill the bar's width. + * @param passThrough take no pointer input at all, so taps reach what is beneath; there is no + * swipe to dismiss and the toast should have no action. + * @param inPlace when a toast is already showing, swap its content without replaying the + * entrance, for a message that updates as the user acts (a countdown). + */ + suspend fun show( + message: String, + icon: ImageVector? = null, + actionLabel: String? = null, + hugContent: Boolean = false, + passThrough: Boolean = false, + inPlace: Boolean = false, + duration: SnackbarDuration = SnackbarDuration.Short, + ): SnackbarResult { + val previous = current + val data = FloatingToastData( + message = message, + icon = icon, + actionLabel = actionLabel, + hugContent = hugContent, + passThrough = passThrough, + duration = duration, + slot = if (inPlace && previous != null) previous.slot else nextSlot++, + result = CompletableDeferred(), + ) + // Set before the previous toast resolves, so the host never sees an empty frame between them. + current = data + previous?.dismiss() + try { + return data.await() + } finally { + if (current === data) current = null + } + } +} + +/** + * The app's toast host, provided at the root so a toast floats with the navigation bar. Null where + * no root host is installed (previews, tests). + */ +val LocalFloatingToastHost = staticCompositionLocalOf { null } + +/** + * Shows [hostState]'s current toast as a [FloatingToast], inset like the navigation bar so it is + * never wider than it. Place it at the bottom of the screen, above the bar, and compose it *before* + * the bar so the bar draws over it. + * + * While [risesFromBar], a toast grows up out of the bar: it starts squashed against its bottom edge + * and pushed down behind the bar, and settles above it. Otherwise it slides up from the bottom. It + * leaves the way it came. A [SnackbarDuration.Short] toast stays 4 seconds, or longer when the + * accessibility settings ask for more time, and a swipe down dismisses it unless it passes through. + * + * [hazeState] must not belong to a `hazeSource` that contains this host. + */ +@Composable +fun FloatingToastHost( + hostState: FloatingToastHostState, + modifier: Modifier = Modifier, + risesFromBar: Boolean = false, + hazeState: HazeState? = null, +) { + val current = hostState.current + val accessibilityManager = LocalAccessibilityManager.current + LaunchedEffect(current) { + if (current == null) return@LaunchedEffect + val millis = when (current.duration) { + SnackbarDuration.Short -> 4_000L + SnackbarDuration.Long -> 10_000L + SnackbarDuration.Indefinite -> return@LaunchedEffect + } + val timeout = accessibilityManager?.calculateRecommendedTimeoutMillis( + originalTimeoutMillis = millis, + containsIcons = current.icon != null, + containsText = true, + containsControls = current.actionLabel != null, + ) ?: millis + delay(timeout) + current.dismiss() + } + + // Read in the draw phase, so a bar that hides mid-toast changes how it leaves without recomposing. + val rises by rememberUpdatedState(risesFromBar) + val riseDistance = with(LocalDensity.current) { RiseDistance.toPx() } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = FloatingChrome.horizontalInset), + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedContent( + targetState = current, + contentKey = { it?.slot }, + transitionSpec = { + fadeIn(tween(EnterFadeMillis)) togetherWith fadeOut(tween(ExitFadeMillis)) using + // Content draws unclipped, so the container has nothing to animate. + SizeTransform(clip = false) { _, _ -> snap() } + }, + contentAlignment = Alignment.BottomCenter, + label = "floatingToast", + ) { data -> + if (data == null) { + // An empty slot, so the outgoing toast can animate away. + Box(Modifier.fillMaxWidth()) + return@AnimatedContent + } + // Driven by the enter/exit transition itself, so AnimatedContent keeps the outgoing toast + // until it has finished leaving. + val progress = transition.animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "floatingToastProgress", + ) { state -> if (state == EnterExitState.Visible) 1f else 0f } + + val motion = Modifier.graphicsLayer { + val p = progress.value + if (rises) { + transformOrigin = TransformOrigin(0.5f, 1f) + scaleY = lerp(RiseStartScaleY, 1f, p) + translationY = (1f - p) * riseDistance + } else { + translationY = (1f - p) * size.height + } + } + SwipeToDismiss(data, motion) { + FloatingToast( + message = data.message, + icon = data.icon, + actionLabel = data.actionLabel, + onAction = data::performAction, + hugContent = data.hugContent, + hazeState = hazeState, + ) + } + } + } +} + +@Composable +private fun SwipeToDismiss( + data: FloatingToastData, + modifier: Modifier, + content: @Composable () -> Unit, +) { + if (data.passThrough) { + // No pointer or dismiss handlers at all: taps go to whatever is beneath. + Box(modifier) { content() } + return + } + val offset = remember(data.slot) { Animatable(0f) } + val scope = rememberCoroutineScope() + val threshold = with(LocalDensity.current) { 32.dp.toPx() } + Box( + modifier = modifier + .graphicsLayer { translationY = offset.value } + .semantics { + dismiss { + data.dismiss() + true + } + } + .pointerInput(data) { + detectVerticalDragGestures( + onDragEnd = { + if (offset.value > threshold) { + data.dismiss() + } else { + scope.launch { offset.animateTo(0f) } + } + }, + onDragCancel = { scope.launch { offset.animateTo(0f) } }, + ) { change, dragAmount -> + change.consume() + scope.launch { offset.snapTo((offset.value + dragAmount).coerceAtLeast(0f)) } + } + }, + ) { + content() + } +} + +// How far below its resting place a rising toast starts: enough to tuck its squashed bottom edge +// behind the bar it grows out of. +private val RiseDistance = 20.dp +private const val RiseStartScaleY = 0.2f +private const val EnterFadeMillis = 180 +private const val ExitFadeMillis = 150 diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt index 49fb1a42e5..e432b746c4 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt @@ -8,57 +8,39 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.indication -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Text -import androidx.compose.material.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalAccessibilityManager import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.dismiss -import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.getcode.theme.CodeTheme -import com.getcode.theme.White -import com.getcode.theme.White10 +import com.getcode.ui.components.glass.FloatingChrome +import com.getcode.ui.components.toast.FloatingToast +import com.getcode.ui.components.toast.LocalFloatingToastHost import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** - * The app's toast: a pill one step lighter than the screen, with a hairline border, holding an - * optional [icon], the message, and the action in its own pill. iOS draws the same design. + * A [SnackbarData] drawn as the app's [FloatingToast], inset like the navigation bar. A screen-level + * host has no bar haze to sample, so the glass takes the bar's no-haze fill. * - * Show it through [CodeSnackbarHost], which owns its motion, lifetime and swipe to dismiss. + * Show it through [CodeSnackbarHost], which owns its motion, lifetime and swipe to dismiss. Prefer + * [LocalFloatingToastHost] on a screen under the navigation bar, so the toast rises out of the bar. */ @Composable fun CodeSnackbar( @@ -66,73 +48,15 @@ fun CodeSnackbar( modifier: Modifier = Modifier, icon: ImageVector? = null, ) { - val actionLabel = snackbarData.actionLabel - Row( + FloatingToast( + message = snackbarData.message, + icon = icon, + actionLabel = snackbarData.actionLabel, + onAction = snackbarData::performAction, modifier = modifier - .fillMaxWidth() - .padding(12.dp) - .clip(CircleShape) - // The nearest token to one step above the screen background (Brand). - .background(CodeTheme.colors.surfaceVariant) - .border(0.5.dp, White10, CircleShape) - .padding( - PaddingValues( - start = 16.dp, - // With an action, its 48dp touch area sets the height instead. - top = if (actionLabel != null) 0.dp else 8.dp, - bottom = if (actionLabel != null) 0.dp else 8.dp, - end = if (actionLabel != null) 8.dp else 16.dp, - ) - ), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - modifier = Modifier.size(18.dp), - ) - } - Text( - text = snackbarData.message, - style = CodeTheme.typography.textSmall, - fontWeight = FontWeight.Normal, - color = CodeTheme.colors.textMain, - modifier = Modifier - .weight(1f) - // The action pill's vertical padding, so a toast is the same height with or without one. - .padding(vertical = 7.dp) - .semantics { liveRegion = LiveRegionMode.Polite }, - ) - if (actionLabel != null) { - val interactionSource = remember { MutableInteractionSource() } - // The touch area is the toast's full height; the ripple stays on the visible pill. - Box( - modifier = Modifier - .heightIn(min = 48.dp) - .clickable( - interactionSource = interactionSource, - indication = null, - role = Role.Button, - ) { snackbarData.performAction() }, - contentAlignment = Alignment.Center, - ) { - Text( - text = actionLabel, - style = CodeTheme.typography.textSmall, - fontWeight = FontWeight.Bold, - color = CodeTheme.colors.textMain, - modifier = Modifier - .clip(CircleShape) - .background(ActionFill) - .indication(interactionSource, ripple()) - .padding(horizontal = 16.dp, vertical = 7.dp), - ) - } - } - } + .padding(horizontal = FloatingChrome.horizontalInset) + .padding(vertical = 12.dp), + ) } /** @@ -221,6 +145,3 @@ private fun SwipeToDismiss(data: SnackbarData, content: @Composable () -> Unit) content() } } - -// No theme token sits at 12%; White10 is the nearest below it. -private val ActionFill = White.copy(alpha = 0.12f) From 163e482e58e8cf7d7d31edf6af2a33b1a3734520 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:15:35 -0400 Subject: [PATCH 31/39] feat(chat): raise the archive undo toast out of the tab bar The toast is hosted at the app root, outside the haze source and under the bar in z, so its glass blurs the list and it grows up from behind the bar. Bar visibility moves into AppNavigationBarVisibility so the bar and the toast read the same route, modal, bill and card-expansion gate. With no bar on screen the toast slides up from the bottom. --- .../app/internal/ui/AppNavigationBar.kt | 139 ++++++++++++------ .../app/internal/ui/navigation/AppContent.kt | 65 +++++++- .../com/flipcash/app/tipping/ChatsScreen.kt | 30 ++-- 3 files changed, 169 insertions(+), 65 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index a250a03088..354b658116 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -10,13 +10,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute @@ -24,6 +25,7 @@ import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet import dev.chrisbanes.haze.HazeState import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.NavigationBar @@ -50,77 +52,128 @@ import kotlinx.coroutines.flow.map * Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be * dropped into any container (it does not require a BoxScope from its caller). */ +/** + * Whether the hoisted navigation bar is on screen, read by the bar itself and by the toast that rises + * out of it, so both answer the same way. + * + * Every value is derived lazily, so only the composables that read one recompose when it changes. + */ +@Stable +internal class AppNavigationBarVisibility( + private val navigator: CodeNavigator, + private val bottomBarMessages: State>, + private val billUp: State, + private val tabBarVisibility: TabBarVisibilityController, + private val cardExpansion: CardExpansionController?, +) { + /** + * The tab the bar highlights. Selection follows the base of the backstack (the tab "home"), so it + * stays correct while a sheet/modal sits on top and is right on launch. + */ + val selectedTab: NavBarButton? by derivedStateOf { + navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() } + } + + /** + * The route, modal and bill gate, which the bar slides in and out on. Only the top route gates + * visibility. A BottomBar modal (e.g. Add Money) renders in the nav content, above the bar, and a + * bill/tip card renders at the app root above everything, so the bar hides under either. A tab + * home can also hide it without leaving its route (the You tab's tip card expanding in place). + */ + val shown: Boolean by derivedStateOf { + (navigator.currentRouteKey as? AppRoute)?.asNavBarTab() != null && + bottomBarMessages.value.isEmpty() && + !billUp.value && + !tabBarVisibility.isHidden + } + + /** + * The wallet's card expansion, which fades the bar out as a card opens over the deck. The + * expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so the + * fade is scoped to that tab: a route change that leaves the wallet mid-expansion would otherwise + * strand the bar faded out with nothing left to bring it back. + */ + val fadeProgress: Float + get() = cardExpansion + ?.takeIf { selectedTab == NavBarButton.Wallet } + ?.progress?.value + ?: 0f + + /** + * Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the + * bar hit-testable, so an invisible bar still took taps and switched tabs. Derived, so a reader + * recomposes on the two frames the boolean flips rather than once per frame of the expansion. + */ + val fadedOut: Boolean by derivedStateOf { fadeProgress >= 1f } + + /** On screen and not faded out by a card expansion. */ + val isVisible: Boolean get() = shown && !fadedOut +} + +@Composable +internal fun rememberAppNavigationBarVisibility( + navigator: CodeNavigator, + tabBarVisibility: TabBarVisibilityController, + cardExpansion: CardExpansionController?, +): AppNavigationBarVisibility { + val bottomBarMessages = BottomBarManager.messages.collectAsStateWithLifecycle() + val session = LocalSessionController.current + val billUp = remember(session) { + session?.billState?.map { it.bill != null } ?: flowOf(false) + }.collectAsStateWithLifecycle(initialValue = false) + return remember(navigator, tabBarVisibility, cardExpansion, bottomBarMessages, billUp) { + AppNavigationBarVisibility(navigator, bottomBarMessages, billUp, tabBarVisibility, cardExpansion) + } +} + +/** + * The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever + * top-level route is a tab home and switches tabs by **swapping the current screen** (single + * backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet). + * + * Visible while [visibility] says so; it fades out with the wallet's card expansion, like iOS's tab + * bar, and drops out of the tree at the end of the fade. + * + * Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be + * dropped into any container (it does not require a BoxScope from its caller). + */ @Composable internal fun AppNavigationBar( navigator: CodeNavigator, + visibility: AppNavigationBarVisibility, modifier: Modifier = Modifier, hazeState: HazeState? = null, - // A tab home taking over the whole screen without leaving its route (the You tab's tip card - // expanding in place). See TabBarVisibilityController. - forceHidden: Boolean = false, - // The wallet's card expansion, which fades this bar out as a card opens over the deck. Passed as - // the controller rather than a Float so the progress is read inside a graphicsLayer and a frame of - // the expansion doesn't recompose the bar. - cardExpansion: CardExpansionController? = null, // The same gate as Switch Accounts in Advanced Features: beta flags unlocked, or staff. canSwitchAccounts: Boolean = false, ) { - // Selection follows the base of the backstack (the tab "home"), so it stays correct while a - // sheet/modal sits on top and is right on launch. The top route only gates visibility. - val selectedTab = navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() } - val topTab = (navigator.currentRouteKey as? AppRoute)?.asNavBarTab() - - // A BottomBar modal (e.g. Add Money) renders in the nav content, above this bar; hide the bar so - // it doesn't draw over the modal. Safe because the bar is a bottom overlay (not a scaffold - // bottomBar), so hiding it doesn't resize the content beneath (see AppContent). - val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle() - - // A bill/tip card renders at the app root above everything; hide the bar so it doesn't show - // beneath the presented bill. - val session = LocalSessionController.current - val billUp by remember(session) { - session?.billState?.map { it.bill != null } ?: flowOf(false) - }.collectAsStateWithLifecycle(initialValue = false) + val selectedTab = visibility.selectedTab // Unread chats in the Chats list (tip DMs and groups), badged onto the Chats tab, so the badge // appears, updates and clears as conversations are read. + val session = LocalSessionController.current val chatListUnreadCount by remember(session) { session?.state?.map { it.chatListUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) val avatar = rememberProfileAvatar() - // The expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so - // scope the fade to that tab. A route change that leaves the wallet mid-expansion would otherwise - // strand the bar faded out with nothing left to bring it back. - val expansion = cardExpansion?.takeIf { selectedTab == NavBarButton.Wallet } - val fadeProgress = { expansion?.progress?.value ?: 0f } - - // Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the - // bar hit-testable, so an invisible bar still took taps and switched tabs. Drop it from the tree at - // the end of the fade instead. derivedStateOf keeps that to the two frames the boolean flips on, - // rather than one recomposition per frame of the expansion. - val fadedOut by remember(expansion) { - derivedStateOf { fadeProgress() >= 1f } - } - Box( modifier = Modifier .then(modifier) - // Fades out with the expansion and back in with the collapse (no abrupt snap on return), - // like iOS's tab bar. At rest progress is 0, so it's fully shown. - .graphicsLayer { alpha = 1f - fadeProgress() }, + // Read in a graphicsLayer so a frame of the expansion doesn't recompose the bar. At rest + // progress is 0, so it's fully shown. + .graphicsLayer { alpha = 1f - visibility.fadeProgress }, contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( - visible = topTab != null && bottomBarMessages.isEmpty() && !billUp && !forceHidden, + visible = visibility.shown, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { // Inside the AnimatedVisibility, not part of its `visible`: the bar is already at alpha 0 // by the time this drops it, so it must not also play the slide-out — and on the way back // it reappears where it stood and fades up, as before. - if (!fadedOut) { + if (!visibility.fadedOut) { val state = rememberNavigationBarState( selectedTab = selectedTab ?: NavBarButton.Wallet, chatListUnreadCount = chatListUnreadCount, diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 9e3be28962..217a142178 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -1,5 +1,20 @@ package com.flipcash.app.internal.ui.navigation +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.flipcash.app.internal.ui.AppNavigationBarVisibility +import com.flipcash.app.internal.ui.rememberAppNavigationBarVisibility +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.FloatingToastHost +import com.getcode.ui.components.toast.FloatingToastHostState +import com.getcode.ui.components.toast.LocalFloatingToastHost +import dev.chrisbanes.haze.HazeState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween @@ -98,6 +113,12 @@ internal fun AppContent( // Lets a tab home hide the bar without leaving its route — the You tab's tip card expands to // full screen in place, so there's no route change for the visibility rule below to notice. val tabBarVisibility = remember { TabBarVisibilityController() } + val barVisibility = rememberAppNavigationBarVisibility(codeNavigator, tabBarVisibility, cardExpansion) + + // Toasts float with the bar, so they are hosted here rather than per screen: outside the haze + // source, so their glass has the nav content to blur, and under the bar in z, so a toast can grow + // out from behind it. + val toasts = remember { FloatingToastHostState() } // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the @@ -124,6 +145,7 @@ internal fun AppContent( CompositionLocalProvider( LocalCardExpansion provides cardExpansion, LocalTabBarVisibility provides tabBarVisibility, + LocalFloatingToastHost provides toasts, ) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. @@ -222,15 +244,23 @@ internal fun AppContent( ) } + // Composed before the bar, so the bar draws over a toast rising out of it. + AppToastHost( + hostState = toasts, + barVisibility = barVisibility, + barHeight = { tabBarHeight.value }, + hazeState = hazeState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + // Single persistent bottom overlay. Latch the tallest measured height so the reserved inset // stays stable when the bar hides for a modal/bill. AppNavigationBar( navigator = codeNavigator, - hazeState = hazeState, - forceHidden = tabBarVisibility.isHidden, // The bar fades itself out with the wallet's card expansion — and drops out of the tree at // the end of the fade, so an invisible bar can't be tapped. See AppNavigationBar. - cardExpansion = cardExpansion, + visibility = barVisibility, + hazeState = hazeState, canSwitchAccounts = canSwitchAccounts, modifier = Modifier .align(Alignment.BottomCenter) @@ -243,4 +273,31 @@ internal fun AppContent( // wallet entry's own composition churn on push/pop. } } -} \ No newline at end of file +} + +/** + * The root toast host. Over a visible navigation bar a toast rests a little above it and grows out + * of it; with no bar it sits where the bar would, above the system navigation bar, and slides up. + */ +@Composable +private fun AppToastHost( + hostState: FloatingToastHostState, + barVisibility: AppNavigationBarVisibility, + barHeight: () -> Dp, + hazeState: HazeState, + modifier: Modifier = Modifier, +) { + val risesFromBar = barVisibility.isVisible + val gap = CodeTheme.dimens.grid.x2 + val systemBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val bottom by animateDpAsState( + targetValue = if (risesFromBar) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, + label = "toastBottom", + ) + FloatingToastHost( + hostState = hostState, + risesFromBar = risesFromBar, + hazeState = hazeState, + modifier = modifier.offset { IntOffset(0, -bottom.roundToPx()) }, + ) +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 507e9e91c4..ffb509f528 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.Archive -import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember @@ -35,10 +34,6 @@ import com.flipcash.app.tipping.internal.chipReveal import com.flipcash.app.tipping.internal.chipVisibleFraction import com.flipcash.shared.chat.ChatListFilter import com.getcode.ui.components.FilterChip -import com.getcode.ui.components.snack.SnackData -import com.getcode.ui.components.snack.showSnackbar -import com.getcode.ui.theme.CodeSnackbar -import com.getcode.ui.theme.CodeSnackbarHost import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -90,6 +85,7 @@ import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SwipeAction @@ -150,7 +146,8 @@ fun ChatsScreen() { } // One row open at a time: swiping another row closes the one left revealed. val revealGroup = rememberSwipeRevealGroup() - val scaffoldState = rememberScaffoldState() + // The root toast host, so the undo rises out of the tab bar on its glass. + val toasts = LocalFloatingToastHost.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current val archivedMessage = stringResource(R.string.message_chatArchived) @@ -158,7 +155,6 @@ fun ChatsScreen() { val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() CodeScaffold( - scaffoldState = scaffoldState, // The list runs the full height and passes under the title bar, which fades it out against // the background at its own edge — the same treatment the chat screen gives its message // list, rather than cutting the list off at the bar. @@ -192,13 +188,6 @@ fun ChatsScreen() { }, ) }, - // The tab bar is hoisted, so without this the snackbar draws underneath it. - snackbarHost = { hostState -> - CodeSnackbarHost(hostState, modifier = Modifier.padding(bottom = tabBarPadding)) { data -> - // Every toast on this screen is the archive undo. - CodeSnackbar(snackbarData = data, icon = Icons.Outlined.Archive) - } - }, ) { barPadding -> LazyColumn( modifier = Modifier @@ -308,10 +297,15 @@ fun ChatsScreen() { onArchive = { chat -> viewModel.archive(chat.chatId) // The row leaves the list at once, so offer to put it back. - scope.launch { - val host = scaffoldState.snackbarHostState - val result = host.showSnackbar(SnackData(message = archivedMessage, actionLabel = undoLabel)) - if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) + if (toasts != null) { + scope.launch { + val result = toasts.show( + message = archivedMessage, + icon = Icons.Outlined.Archive, + actionLabel = undoLabel, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) + } } }, revealGroup = revealGroup, From f57fa1366ceb98e603d1c729a8d216c920fd4157 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:16:39 -0400 Subject: [PATCH 32/39] feat(menu): show the developer-mode countdown on the floating toast The countdown, unlocked and already-unlocked messages leave the system Toast for FloatingToast. The toast hugs the message, swaps it in place on each tap, and takes no pointer input, so the version taps beneath it keep counting. --- .../com/flipcash/app/menu/MenuScreen.kt | 23 +++++++++++++++++++ .../app/menu/internal/MenuScreenViewModel.kt | 17 +++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt index 71cc14bd27..5793309ab6 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt @@ -6,10 +6,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.menu.internal.MenuScreenContent import com.flipcash.app.menu.internal.MenuScreenViewModel import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.toast.LocalFloatingToastHost import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @Composable fun MenuScreen() { @@ -17,6 +19,7 @@ fun MenuScreen() { MenuScreenContent(viewModel) val navigator = LocalCodeNavigator.current + val toasts = LocalFloatingToastHost.current LaunchedEffect(viewModel) { viewModel.eventFlow @@ -25,4 +28,24 @@ fun MenuScreen() { .onEach { navigator.push(it) } .launchIn(this) } + + // The menu is a tab home, so this rises out of the tab bar, over the version row the taps land + // on. It hugs the message and passes taps through, so each tap still reaches the row; each + // update swaps the text in place and restarts the timeout rather than replaying the entrance. + LaunchedEffect(viewModel, toasts) { + if (toasts == null) return@LaunchedEffect + viewModel.eventFlow + .filterIsInstance() + .onEach { event -> + launch { + toasts.show( + message = event.message, + hugContent = true, + passThrough = true, + inPlace = true, + ) + } + } + .launchIn(this) + } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index f76bf29b7a..6e6177a377 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -160,6 +160,12 @@ internal class MenuScreenViewModel @Inject constructor( val source: AddMoneySource = AddMoneySource.MENU, ) : Event data class OpenScreen(val screen: AppRoute) : Event + + /** + * A developer-mode unlock message (the tap countdown, then unlocked). The screen floats it + * over the version row without taking taps, so the taps keep counting through it. + */ + data class ShowDevModeToast(val message: String) : Event data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event data class OnUsernameProgressChanged( val progress: UsernameProgress?, @@ -220,7 +226,7 @@ internal class MenuScreenViewModel @Inject constructor( .onEach { if (stateFlow.value.unlockedBetaFeaturesManually) { if (stateFlow.value.logoTapCount - TAP_THRESHOLD > COUNTDOWN_START) { - toastController.showToast(R.string.toast_betaOverrideAlready, replacePrevious = true) + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideAlready))) } return@onEach } @@ -228,10 +234,14 @@ internal class MenuScreenViewModel @Inject constructor( when { remaining <= 0 -> { featureFlags.enableBetaFeatures() - toastController.showToast(R.string.toast_betaOverrideEnabled, replacePrevious = true) + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideEnabled))) } remaining <= COUNTDOWN_START -> { - toastController.showQuantityToast(R.plurals.toast_betaOverrideCountdown, remaining, remaining, replacePrevious = true) + dispatchEvent( + Event.ShowDevModeToast( + resources.getQuantityString(R.plurals.toast_betaOverrideCountdown, remaining, remaining) + ) + ) } } } @@ -580,6 +590,7 @@ internal class MenuScreenViewModel @Inject constructor( Event.CopyTipLink, Event.DownloadTipCard, is Event.ExportTipCard, + is Event.ShowDevModeToast, is Event.OpenScreen -> { state -> state } is Event.OnFeatureFlagsUpdated -> { state -> From 8881dd4e28362f15ed2680876f161e99c4ed4a24 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:26:37 -0400 Subject: [PATCH 33/39] fix(ui): dismiss the floating toast while a sheet, the bill or a prompt is up The root toast host draws above the nav content, so a toast shown before a sheet, the bill or a BottomBarManager prompt opened stayed on top of it and took its taps (archive a chat, then open Mute on another within 4s). Covering the host now dismisses the toast on screen, and a toast shown while covered returns Dismissed without appearing, so the Menu countdown's next tick cannot bring it back over a sheet. --- .../app/internal/ui/AppNavigationBar.kt | 12 +++ .../app/internal/ui/navigation/AppContent.kt | 5 ++ .../ui/components/toast/FloatingToastHost.kt | 13 ++++ .../toast/FloatingToastHostStateTest.kt | 78 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 354b658116..e3477c4b79 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -35,6 +35,7 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.user.AuthState import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.manager.BottomBarManager +import com.getcode.navigation.Sheet import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.glass.FloatingChrome @@ -106,6 +107,17 @@ internal class AppNavigationBarVisibility( */ val fadedOut: Boolean by derivedStateOf { fadeProgress >= 1f } + /** + * A sheet, the bill or a bottom-bar prompt is up. Each renders inside the nav content, below the + * root toast host in z, so a toast is dismissed while one is up rather than drawn over it. An + * ordinary push hides the bar but covers nothing, so it is not counted. + */ + val coversToast: Boolean by derivedStateOf { + navigator.currentRouteKey is Sheet || + bottomBarMessages.value.isNotEmpty() || + billUp.value + } + /** On screen and not faded out by a card expansion. */ val isVisible: Boolean get() = shown && !fadedOut } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 217a142178..af0679b446 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -119,6 +119,11 @@ internal fun AppContent( // source, so their glass has the nav content to blur, and under the bar in z, so a toast can grow // out from behind it. val toasts = remember { FloatingToastHostState() } + // The host draws above the nav content, so it must stand down while a sheet, the bill or a + // bottom-bar prompt is up there rather than float over it and take its taps. + LaunchedEffect(toasts, barVisibility) { + snapshotFlow { barVisibility.coversToast }.collect { toasts.isCovered = it } + } // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt index fa20744bd5..dbce8c4289 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt @@ -81,6 +81,18 @@ class FloatingToastHostState { var current: FloatingToastData? by mutableStateOf(null) private set + /** + * Whether something that must not be drawn over (a sheet, a modal, a prompt) is on screen above + * the host. Covering dismisses the toast on screen, and a toast shown while covered is dismissed + * without appearing: a toast left on top would take that layer's taps, and one hidden beneath it + * would time out unseen with its action unreachable. + */ + var isCovered: Boolean = false + set(value) { + field = value + if (value) current?.dismiss() + } + private var nextSlot = 0L /** @@ -102,6 +114,7 @@ class FloatingToastHostState { inPlace: Boolean = false, duration: SnackbarDuration = SnackbarDuration.Short, ): SnackbarResult { + if (isCovered) return SnackbarResult.Dismissed val previous = current val data = FloatingToastData( message = message, diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt new file mode 100644 index 0000000000..d1995ca34f --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt @@ -0,0 +1,78 @@ +package com.getcode.ui.components.toast + +import androidx.compose.material.SnackbarResult +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The host draws above the nav content, so a sheet, the bill or a bottom-bar prompt covers it. A + * toast left showing over one of those takes its taps, so covering has to take the toast down and + * keep new ones from appearing until it is uncovered. + */ +class FloatingToastHostStateTest { + + @Test + fun `covering the host dismisses the toast on screen`() = runTest { + val host = FloatingToastHostState() + val shown = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + assertEquals("Chat archived", host.current?.message) + + host.isCovered = true + + assertEquals(SnackbarResult.Dismissed, shown.await()) + assertNull(host.current) + } + + @Test + fun `a toast shown while covered never appears`() = runTest { + val host = FloatingToastHostState() + host.isCovered = true + + assertEquals(SnackbarResult.Dismissed, host.show("3 taps away")) + assertNull(host.current) + } + + @Test + fun `an in-place update while covered does not bring the toast back`() = runTest { + val host = FloatingToastHostState() + val first = async(start = CoroutineStart.UNDISPATCHED) { + host.show("4 taps away", inPlace = true) + } + host.isCovered = true + assertEquals(SnackbarResult.Dismissed, first.await()) + + assertEquals(SnackbarResult.Dismissed, host.show("3 taps away", inPlace = true)) + assertNull(host.current) + } + + @Test + fun `uncovering lets the next toast show`() = runTest { + val host = FloatingToastHostState() + host.isCovered = true + host.isCovered = false + + val shown = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + assertEquals("Chat archived", host.current?.message) + host.current!!.performAction() + + assertEquals(SnackbarResult.ActionPerformed, shown.await()) + } + + @Test + fun `a new toast replaces the one on screen`() = runTest { + val host = FloatingToastHostState() + val first = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + val second = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived again") } + + assertEquals(SnackbarResult.Dismissed, first.await()) + assertTrue(second.isActive) + assertEquals("Chat archived again", host.current?.message) + host.current!!.dismiss() + second.await() + } +} From e9dbd72d2b12936a96e60102ddbcaac7e18e905d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:26:38 -0400 Subject: [PATCH 34/39] chore(ui): remove the unused snackbar host CodeSnackbar, CodeSnackbarHost and SnackData.showSnackbar had no callers after the move to FloatingToast; CodeScaffold's default snackbar host is now empty. --- .../getcode/ui/components/snack/SnackData.kt | 23 --- .../com/getcode/ui/theme/CodeScaffold.kt | 6 +- .../com/getcode/ui/theme/CodeSnackbar.kt | 147 ------------------ 3 files changed, 3 insertions(+), 173 deletions(-) delete mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt delete mode 100644 ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt deleted file mode 100644 index 8cd40bb55e..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.getcode.ui.components.snack - -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.SnackbarResult - -data class SnackData( - val message: String, - val actionLabel: String? = null, - val duration: SnackbarDuration = SnackbarDuration.Short -) - -/** - * Shows [data], replacing any snackbar already on screen rather than queueing behind it. - */ -suspend fun SnackbarHostState.showSnackbar(data: SnackData): SnackbarResult { - currentSnackbarData?.dismiss() - return showSnackbar( - message = data.message, - actionLabel = data.actionLabel, - duration = data.duration - ) -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index fc5992dfef..27a30e1541 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -37,9 +37,9 @@ fun CodeScaffold( scaffoldState: ScaffoldState = rememberScaffoldState(), topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { - CodeSnackbarHost(it) - }, + // Toasts go through the app's root FloatingToastHost (LocalFloatingToastHost), so a scaffold + // hosts none of its own. + snackbarHost: @Composable (SnackbarHostState) -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, isFloatingActionButtonDocked: Boolean = false, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt deleted file mode 100644 index e432b746c4..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.getcode.ui.theme - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.SnackbarData -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalAccessibilityManager -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.dismiss -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.getcode.ui.components.glass.FloatingChrome -import com.getcode.ui.components.toast.FloatingToast -import com.getcode.ui.components.toast.LocalFloatingToastHost -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * A [SnackbarData] drawn as the app's [FloatingToast], inset like the navigation bar. A screen-level - * host has no bar haze to sample, so the glass takes the bar's no-haze fill. - * - * Show it through [CodeSnackbarHost], which owns its motion, lifetime and swipe to dismiss. Prefer - * [LocalFloatingToastHost] on a screen under the navigation bar, so the toast rises out of the bar. - */ -@Composable -fun CodeSnackbar( - snackbarData: SnackbarData, - modifier: Modifier = Modifier, - icon: ImageVector? = null, -) { - FloatingToast( - message = snackbarData.message, - icon = icon, - actionLabel = snackbarData.actionLabel, - onAction = snackbarData::performAction, - modifier = modifier - .padding(horizontal = FloatingChrome.horizontalInset) - .padding(vertical = 12.dp), - ) -} - -/** - * Shows [hostState]'s current snackbar with [snackbar], one at a time. It slides up and fades in, - * leaves the same way, and a swipe down dismisses it. A [SnackbarDuration.Short] snackbar stays 4 - * seconds, or longer when the accessibility settings ask for more time. - * - * Material's `SnackbarHost` is not used because its fade and scale cannot be turned off and - * would play over the slide. - */ -@Composable -fun CodeSnackbarHost( - hostState: SnackbarHostState, - modifier: Modifier = Modifier, - snackbar: @Composable (SnackbarData) -> Unit = { CodeSnackbar(it) }, -) { - val current = hostState.currentSnackbarData - val accessibilityManager = LocalAccessibilityManager.current - LaunchedEffect(current) { - if (current == null) return@LaunchedEffect - val millis = when (current.duration) { - SnackbarDuration.Short -> 4_000L - SnackbarDuration.Long -> 10_000L - SnackbarDuration.Indefinite -> return@LaunchedEffect - } - val timeout = accessibilityManager?.calculateRecommendedTimeoutMillis( - originalTimeoutMillis = millis, - containsIcons = true, - containsText = true, - containsControls = current.actionLabel != null, - ) ?: millis - delay(timeout) - current.dismiss() - } - - AnimatedContent( - targetState = current, - modifier = modifier, - transitionSpec = { - (slideInVertically { it } + fadeIn()) togetherWith - (slideOutVertically { it } + fadeOut()) using - SizeTransform(clip = false) - }, - contentAlignment = Alignment.BottomCenter, - label = "snackbar", - ) { data -> - if (data != null) { - SwipeToDismiss(data) { snackbar(data) } - } else { - // An empty slot, so the outgoing snackbar can animate away. - Box(Modifier.fillMaxWidth()) - } - } -} - -@Composable -private fun SwipeToDismiss(data: SnackbarData, content: @Composable () -> Unit) { - val offset = remember(data) { Animatable(0f) } - val scope = rememberCoroutineScope() - val threshold = with(LocalDensity.current) { 32.dp.toPx() } - Box( - modifier = Modifier - .graphicsLayer { translationY = offset.value } - .semantics { - dismiss { - data.dismiss() - true - } - } - .pointerInput(data) { - detectVerticalDragGestures( - onDragEnd = { - if (offset.value > threshold) { - data.dismiss() - } else { - scope.launch { offset.animateTo(0f) } - } - }, - onDragCancel = { scope.launch { offset.animateTo(0f) } }, - ) { change, dragAmount -> - change.consume() - scope.launch { offset.snapTo((offset.value + dragAmount).coerceAtLeast(0f)) } - } - }, - ) { - content() - } -} From 0a8b59d1bfd9ae828aad68fb6704a62e34d8dcb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 06:26:38 -0400 Subject: [PATCH 35/39] chore(nav): drop the orphaned navigation bar KDoc It sat above AppNavigationBarVisibility's own KDoc; AppNavigationBar already carries a fuller copy. --- .../com/flipcash/app/internal/ui/AppNavigationBar.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index e3477c4b79..dbd177e145 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -43,16 +43,6 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -/** - * The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever - * top-level route is a tab home and switches tabs by **swapping the current screen** (single - * backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet). - * - * Only visible when the current route maps to a tab. - * - * Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be - * dropped into any container (it does not require a BoxScope from its caller). - */ /** * Whether the hoisted navigation bar is on screen, read by the bar itself and by the toast that rises * out of it, so both answer the same way. From 3c744573a9f051460d0980a55d9cc8b4873e1b09 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 08:54:46 -0400 Subject: [PATCH 36/39] fix(chat): centre the Archived screen's title like the other back-button screens --- .../main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt index 24bb54641c..26439d64e6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -74,6 +74,7 @@ private fun ArchivedChatsContent( topBar = { AppBarWithTitle( title = stringResource(R.string.title_archived), + titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = onBack, ) }, From df88214ae66d0d6b10633cbb87ce0d5ef9b911a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 08:54:47 -0400 Subject: [PATCH 37/39] fix(ui): set the toast message in Avenir Next Medium to match iOS The theme's Medium weight resolves to the Demi file, and the message had been forced to Normal, which drew the Regular cut and read as a different face from the rest of the app. --- .../com/getcode/ui/components/toast/FloatingToast.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt index 6607464a2a..f55564aa6e 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt @@ -26,9 +26,12 @@ import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme +import com.getcode.theme.R import com.getcode.theme.White import com.getcode.ui.components.glass.floatingGlass import dev.chrisbanes.haze.HazeState @@ -81,8 +84,7 @@ fun FloatingToast( } Text( text = message, - style = CodeTheme.typography.textSmall, - fontWeight = FontWeight.Normal, + style = CodeTheme.typography.textSmall.copy(fontFamily = AvenirMedium, fontWeight = FontWeight.Medium), color = CodeTheme.colors.textMain, modifier = Modifier // Weighted children measure after the fixed ones, so a long message wraps rather @@ -124,3 +126,7 @@ fun FloatingToast( // No theme token sits at 12%; White10 is the nearest below it. private val ActionFill = White.copy(alpha = 0.12f) + +// The theme maps Medium to the Demi file, so name the Medium cut directly: iOS sets this message in +// Avenir Next Medium. +private val AvenirMedium = FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) From dcf8132ecc5c72649f8684e5d3a7c0f08490326c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 08:54:47 -0400 Subject: [PATCH 38/39] fix(ui): slide and fade the toast in, and reverse it on the way out Replaces the grow-out-of-the-bar entrance. One progress value drives both the offset and the alpha, so leaving plays the entrance backwards. --- .../app/internal/ui/navigation/AppContent.kt | 8 ++--- .../ui/components/toast/FloatingToastHost.kt | 36 +++++-------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index af0679b446..c7b28bb77e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -281,8 +281,8 @@ internal fun AppContent( } /** - * The root toast host. Over a visible navigation bar a toast rests a little above it and grows out - * of it; with no bar it sits where the bar would, above the system navigation bar, and slides up. + * The root toast host. Over a visible navigation bar a toast rests a little above it; with no bar it + * sits where the bar would, above the system navigation bar. */ @Composable private fun AppToastHost( @@ -292,16 +292,14 @@ private fun AppToastHost( hazeState: HazeState, modifier: Modifier = Modifier, ) { - val risesFromBar = barVisibility.isVisible val gap = CodeTheme.dimens.grid.x2 val systemBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottom by animateDpAsState( - targetValue = if (risesFromBar) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, + targetValue = if (barVisibility.isVisible) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, label = "toastBottom", ) FloatingToastHost( hostState = hostState, - risesFromBar = risesFromBar, hazeState = hazeState, modifier = modifier.offset { IntOffset(0, -bottom.roundToPx()) }, ) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt index dbce8c4289..3e0a4fcaf8 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt @@ -2,15 +2,14 @@ package com.getcode.ui.components.toast import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box @@ -26,12 +25,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput @@ -40,7 +37,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.dismiss import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import com.getcode.ui.components.glass.FloatingChrome import dev.chrisbanes.haze.HazeState import kotlinx.coroutines.CompletableDeferred @@ -148,10 +144,9 @@ val LocalFloatingToastHost = staticCompositionLocalOf { * never wider than it. Place it at the bottom of the screen, above the bar, and compose it *before* * the bar so the bar draws over it. * - * While [risesFromBar], a toast grows up out of the bar: it starts squashed against its bottom edge - * and pushed down behind the bar, and settles above it. Otherwise it slides up from the bottom. It - * leaves the way it came. A [SnackbarDuration.Short] toast stays 4 seconds, or longer when the - * accessibility settings ask for more time, and a swipe down dismisses it unless it passes through. + * A toast slides up by its own height while fading in, and leaves the same way in reverse. A + * [SnackbarDuration.Short] toast stays 4 seconds, or longer when the accessibility settings ask for + * more time, and a swipe down dismisses it unless it passes through. * * [hazeState] must not belong to a `hazeSource` that contains this host. */ @@ -159,7 +154,6 @@ val LocalFloatingToastHost = staticCompositionLocalOf { fun FloatingToastHost( hostState: FloatingToastHostState, modifier: Modifier = Modifier, - risesFromBar: Boolean = false, hazeState: HazeState? = null, ) { val current = hostState.current @@ -181,10 +175,6 @@ fun FloatingToastHost( current.dismiss() } - // Read in the draw phase, so a bar that hides mid-toast changes how it leaves without recomposing. - val rises by rememberUpdatedState(risesFromBar) - val riseDistance = with(LocalDensity.current) { RiseDistance.toPx() } - Box( modifier = modifier .fillMaxWidth() @@ -195,7 +185,8 @@ fun FloatingToastHost( targetState = current, contentKey = { it?.slot }, transitionSpec = { - fadeIn(tween(EnterFadeMillis)) togetherWith fadeOut(tween(ExitFadeMillis)) using + // The motion lives on the progress below, so entering and leaving share one curve. + EnterTransition.None togetherWith ExitTransition.None using // Content draws unclipped, so the container has nothing to animate. SizeTransform(clip = false) { _, _ -> snap() } }, @@ -221,13 +212,8 @@ fun FloatingToastHost( val motion = Modifier.graphicsLayer { val p = progress.value - if (rises) { - transformOrigin = TransformOrigin(0.5f, 1f) - scaleY = lerp(RiseStartScaleY, 1f, p) - translationY = (1f - p) * riseDistance - } else { - translationY = (1f - p) * size.height - } + alpha = p + translationY = (1f - p) * size.height } SwipeToDismiss(data, motion) { FloatingToast( @@ -288,7 +274,3 @@ private fun SwipeToDismiss( // How far below its resting place a rising toast starts: enough to tuck its squashed bottom edge // behind the bar it grows out of. -private val RiseDistance = 20.dp -private const val RiseStartScaleY = 0.2f -private const val EnterFadeMillis = 180 -private const val ExitFadeMillis = 150 From 945f237385523f6137f8a2b6c31985f4dfe0bbcc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 09:54:08 -0400 Subject: [PATCH 39/39] chore(ui): drop CodeScaffold's snackbarHost slot Toasts go through the app's root FloatingToastHost, and nothing passed a host to this slot any more. --- .../src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index 27a30e1541..221e24d415 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -6,7 +6,6 @@ import androidx.compose.material.DrawerDefaults import androidx.compose.material.FabPosition import androidx.compose.material.Scaffold import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHostState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -37,9 +36,6 @@ fun CodeScaffold( scaffoldState: ScaffoldState = rememberScaffoldState(), topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, - // Toasts go through the app's root FloatingToastHost (LocalFloatingToastHost), so a scaffold - // hosts none of its own. - snackbarHost: @Composable (SnackbarHostState) -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, isFloatingActionButtonDocked: Boolean = false, @@ -64,7 +60,8 @@ fun CodeScaffold( // exists to avoid. topBar = if (isOverlay) ({}) else topBar, bottomBar = if (isOverlay) ({}) else bottomBar, - snackbarHost = snackbarHost, + // Toasts go through the app's root FloatingToastHost (LocalFloatingToastHost). + snackbarHost = {}, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, isFloatingActionButtonDocked = isFloatingActionButtonDocked,