From 9e70a47a50360a95504e1e4a6932f9fa8ef89ba7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 12:48:57 -0500 Subject: [PATCH 01/16] feat(persistence): chat_marked_unread table for locally marked chats --- .../52.json | 1255 +++++++++++++++++ .../dao/ChatMarkedUnreadDaoTest.kt | 64 + .../app/persistence/FlipcashDatabase.kt | 10 +- .../persistence/dao/ChatMarkedUnreadDao.kt | 28 + .../entities/ChatMarkedUnreadEntity.kt | 23 + .../sources/ChatMarkedUnreadDataSource.kt | 42 + 6 files changed, 1421 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json create mode 100644 apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json new file mode 100644 index 0000000000..b338880b72 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/52.json @@ -0,0 +1,1255 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "a26e7172f36684cb72a298672d897014", + "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, `cover_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, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `can_pin` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, 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": "coverPictureJson", + "columnName": "cover_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": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canPin", + "columnName": "can_pin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "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": "chat_marked_unread", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `marked_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedAt", + "columnName": "marked_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" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + }, + { + "tableName": "group_key_envelopes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `own_pk_hex` TEXT NOT NULL, `scheme` INTEGER NOT NULL, `nonce` BLOB NOT NULL, `ciphertext` BLOB NOT NULL, `wrapped_by` BLOB, `wrapped_by_public_key` BLOB, PRIMARY KEY(`chat_id_hex`, `own_pk_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownPkHex", + "columnName": "own_pk_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheme", + "columnName": "scheme", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "wrappedBy", + "columnName": "wrapped_by", + "affinity": "BLOB" + }, + { + "fieldPath": "wrappedByPublicKey", + "columnName": "wrapped_by_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "own_pk_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a26e7172f36684cb72a298672d897014')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt b/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt new file mode 100644 index 0000000000..dc40941837 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/androidHostTest/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDaoTest.kt @@ -0,0 +1,64 @@ +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.ChatMarkedUnreadEntity +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 + +@RunWith(RobolectricTestRunner::class) +class ChatMarkedUnreadDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ChatMarkedUnreadDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + FlipcashDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.chatMarkedUnreadDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `marking twice keeps one row`() = runTest { + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", markedAt = 1_000L)) + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", markedAt = 9_000L)) + assertEquals(listOf("aa"), dao.observeIds().first()) + } + + @Test + fun `delete removes only the named chat`() = runTest { + dao.insertIfAbsent(ChatMarkedUnreadEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatMarkedUnreadEntity("bb", 1_000L)) + dao.delete("bb") + assertEquals(setOf("aa"), dao.observeIds().first().toSet()) + } + + @Test + fun `delete by set removes only the named chats`() = runTest { + listOf("aa", "bb", "cc").forEach { dao.insertIfAbsent(ChatMarkedUnreadEntity(it, 1_000L)) } + dao.deleteAll(listOf("aa", "cc")) + assertEquals(setOf("bb"), dao.observeIds().first().toSet()) + } + + @Test + fun `deleteAll empties the table`() = runTest { + listOf("aa", "bb").forEach { dao.insertIfAbsent(ChatMarkedUnreadEntity(it, 1_000L)) } + dao.deleteAll() + assertEquals(emptyList(), dao.observeIds().first()) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index d71a84f82a..f26e74d839 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -19,6 +19,7 @@ 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.ChatMarkedUnreadDao import com.flipcash.app.persistence.dao.ChatDraftDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao @@ -34,6 +35,7 @@ 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.ChatMarkedUnreadEntity import com.flipcash.app.persistence.entities.ChatDraftEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity @@ -70,6 +72,7 @@ import kotlin.jvm.Synchronized ChatMemberEntity::class, ChatDraftEntity::class, ChatArchiveEntity::class, + ChatMarkedUnreadEntity::class, BlockedUserEntity::class, UserProfileEntity::class, LinkPreviewEntity::class, @@ -145,8 +148,11 @@ import kotlin.jvm.Synchronized // AutoMigration for the same reason as payment_requests. AutoMigration(from = 49, to = 50), // group_key_envelopes table AutoMigration(from = 50, to = 51, spec = FlipcashDatabase.Migration50To51::class), // chat_metadata.can_pin (default 0) + // The chat_marked_unread table, an AutoMigration for the same reason as chat_archive: a + // mark cannot be re-fetched until the server stores it. + AutoMigration(from = 51, to = 52), // chat_marked_unread table ], - version = 51, + version = 52, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) @ConstructedBy(FlipcashDatabaseConstructor::class) @@ -161,6 +167,8 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMemberDao(): ChatMemberDao abstract fun chatDraftDao(): ChatDraftDao abstract fun chatArchiveDao(): ChatArchiveDao + + abstract fun chatMarkedUnreadDao(): ChatMarkedUnreadDao abstract fun blockedUserDao(): BlockedUserDao abstract fun userProfileDao(): UserProfileDao abstract fun linkPreviewDao(): LinkPreviewDao diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt new file mode 100644 index 0000000000..91c6482a3d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMarkedUnreadDao.kt @@ -0,0 +1,28 @@ +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.ChatMarkedUnreadEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatMarkedUnreadDao { + + /** IGNORE, not REPLACE: marking a marked chat is a no-op and keeps its timestamp. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(mark: ChatMarkedUnreadEntity) + + @Query("SELECT chat_id_hex FROM chat_marked_unread") + fun observeIds(): Flow> + + @Query("DELETE FROM chat_marked_unread WHERE chat_id_hex = :chatIdHex") + suspend fun delete(chatIdHex: String) + + @Query("DELETE FROM chat_marked_unread WHERE chat_id_hex IN (:chatIdHexes)") + suspend fun deleteAll(chatIdHexes: List) + + @Query("DELETE FROM chat_marked_unread") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt new file mode 100644 index 0000000000..a274b64862 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMarkedUnreadEntity.kt @@ -0,0 +1,23 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A chat the viewer marked as unread. Presence of the row is the whole state. + * + * Its own table for the same reason as [ChatArchiveEntity]: `chat_metadata` is rewritten by every + * feed sync, and the mark is client-owned until the server stores it. + * + * A row is written by Mark as Unread and removed by Mark as Read, Read All, opening the chat, + * leaving the group, or account erasure. A new message in the chat leaves it alone. + */ +@Entity(tableName = "chat_marked_unread") +data class ChatMarkedUnreadEntity( + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "marked_at") + val markedAt: Long, +) diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt new file mode 100644 index 0000000000..224d09be70 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMarkedUnreadDataSource.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatMarkedUnreadEntity +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_marked_unread` table, keyed by chat id hex. + * + * Like [ChatArchiveDataSource], no database means nothing marked, and [observeIds] then emits an + * empty set rather than nothing: it is combined into the feed, which would otherwise wait on it. + */ +@Singleton +class ChatMarkedUnreadDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeIds(): Flow> = + db?.chatMarkedUnreadDao()?.observeIds()?.map { it.toSet() } ?: flowOf(emptySet()) + + suspend fun mark(chatIdHex: String, now: Long) { + db?.chatMarkedUnreadDao()?.insertIfAbsent(ChatMarkedUnreadEntity(chatIdHex, markedAt = now)) + } + + suspend fun clear(chatIdHex: String) { + db?.chatMarkedUnreadDao()?.delete(chatIdHex) + } + + suspend fun clear(chatIdHexes: Collection) { + if (chatIdHexes.isEmpty()) return + db?.chatMarkedUnreadDao()?.deleteAll(chatIdHexes.toList()) + } + + suspend fun clearAll() { + db?.chatMarkedUnreadDao()?.deleteAll() + } +} From 151607fd77bb576fedbaa31d96a2291282691fcd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 12:53:37 -0500 Subject: [PATCH 02/16] feat(chat): ChatMarkedUnreadStore and the read rules behind mark unread A local store beside ChatArchiveStore, folded into the feed in the same combine, so a marked chat counts as unread at the one filter point the tab badge and chips read. Opening a chat, leaving a group and account erasure clear the mark. --- .../chat/RealChatMarkedUnreadStoreTest.kt | 73 +++++++ .../com/flipcash/shared/chat/ChatList.kt | 8 +- .../shared/chat/ChatMarkedUnreadStore.kt | 42 ++++ .../flipcash/shared/chat/ChatReadActions.kt | 53 +++++ .../com/flipcash/shared/chat/ChatReadRules.kt | 65 ++++++ .../com/flipcash/shared/chat/ChatState.kt | 13 +- .../flipcash/shared/chat/inject/ChatModule.kt | 8 + .../chat/internal/RealChatCoordinator.kt | 16 ++ .../internal/RealChatMarkedUnreadStore.kt | 36 +++ .../internal/delegates/FeedSyncDelegate.kt | 14 +- .../chat/ChatCoordinatorTeardownTest.kt | 56 ++++- .../shared/chat/ChatMarkedUnreadFeedTest.kt | 205 ++++++++++++++++++ .../shared/chat/ChatReadActionsTest.kt | 157 ++++++++++++++ 13 files changed, 735 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt create mode 100644 apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt create mode 100644 apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt create mode 100644 apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt create mode 100644 apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt create mode 100644 apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt create mode 100644 apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt diff --git a/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt new file mode 100644 index 0000000000..6205139d92 --- /dev/null +++ b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt @@ -0,0 +1,73 @@ +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.init +import com.flipcash.app.persistence.sources.ChatMarkedUnreadDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.internal.RealChatMarkedUnreadStore +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 + +@RunWith(RobolectricTestRunner::class) +class RealChatMarkedUnreadStoreTest { + + private val a = ChatId("aabbccdd") + private val b = ChatId("11223344") + private val c = ChatId("55667788") + private val store: ChatMarkedUnreadStore = RealChatMarkedUnreadStore(ChatMarkedUnreadDataSource()) { 1_000L } + + @Before + fun setUp() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "chat-marked-unread-store-test") + runBlocking { store.clearAll() } + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + @Test + fun `mark adds the chat and observeMarked reports it`() = runBlocking { + store.mark(a) + assertEquals(setOf(a), store.observeMarked().first()) + } + + @Test + fun `clear removes only that chat`() = runBlocking { + store.mark(a) + store.mark(b) + store.clear(a) + assertEquals(setOf(b), store.observeMarked().first()) + } + + @Test + fun `clear by set removes only those chats`() = runBlocking { + listOf(a, b, c).forEach { store.mark(it) } + store.clear(listOf(a, c)) + assertEquals(setOf(b), store.observeMarked().first()) + } + + // Test case 8: logout empties the marked set. + @Test + fun `clearAll empties the marked set`() = runBlocking { + store.mark(a) + store.mark(b) + store.clearAll() + assertEquals(emptySet(), store.observeMarked().first()) + } + + @Test + fun `no database means nothing marked`() = runBlocking { + FlipcashDatabase.closeDb() + assertEquals(emptySet(), store.observeMarked().first()) + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt index ee2fdc8246..aea43d91d7 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -21,12 +21,12 @@ fun FeedOperations.chatListFeed(): Flow> = feed(*chatListTypes /** [FeedOperations.currentFeed] for the chats the Chats list shows. */ fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chatListTypes) -/** How many of the chats the Chats list shows have unread messages. */ +/** How many of the chats the Chats list shows are unread: unread messages or marked unread. */ fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) /** - * The Chats tab badge: the chats the list shows that read as unread, which is unread messages or, - * on a group [selfId] hosts, someone waiting in [lobbies] to join. Counted the way + * The Chats tab badge: the chats the list shows that read as unread, which is unread messages, a mark, + * or, on a group [selfId] hosts, someone waiting in [lobbies] to join. Counted the way * [projectChatList] counts its Unread chip, so the badge and the chip agree. */ fun FeedOperations.observeUnreadChatListCount( @@ -34,7 +34,7 @@ fun FeedOperations.observeUnreadChatListCount( selfId: () -> ID?, ): Flow = combine(feed(*chatListTypes), lobbies) { summaries, waiting -> val self = selfId() - summaries.count { it.unreadCount != 0 || waiting.waitingFor(it.metadata, self).isNotEmpty() } + summaries.count { it.isUnread || waiting.waitingFor(it.metadata, self).isNotEmpty() } } /** [FeedOperations.archivedFeed] for the chats the Chats list shows. */ diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt new file mode 100644 index 0000000000..5699396742 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadStore.kt @@ -0,0 +1,42 @@ +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 marked as unread, kept on this device. + * + * A mark is set only by Mark as Unread. It is cleared by Mark as Read, Read All, opening the chat, + * leaving a group and account erasure. A new message in a marked chat leaves the mark alone, as + * do archive and unarchive. + * + * Every caller goes through this interface, so a server-backed store can replace this local one + * without a caller changing. Nothing here reaches the network. + */ +interface ChatMarkedUnreadStore { + + /** The marked chats, re-emitted on every change. Emits an empty set before sign-in. */ + fun observeMarked(): Flow> + + /** Marks [chatId]. Marking a marked chat does nothing. */ + suspend fun mark(chatId: ChatId) + + /** Clears the mark on [chatId], if it has one. */ + suspend fun clear(chatId: ChatId) + + /** Clears the mark on each of [chatIds]. Read All and bulk Mark as Read. */ + suspend fun clear(chatIds: Collection) + + /** Drops every mark. Account erasure. */ + suspend fun clearAll() + + /** Marks nothing and ignores writes; the default for constructors tests build directly. */ + object None : ChatMarkedUnreadStore { + override fun observeMarked(): Flow> = flowOf(emptySet()) + override suspend fun mark(chatId: ChatId) = Unit + override suspend fun clear(chatId: ChatId) = Unit + override suspend fun clear(chatIds: Collection) = Unit + override suspend fun clearAll() = Unit + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt new file mode 100644 index 0000000000..2d4a146ea2 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadActions.kt @@ -0,0 +1,53 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Mark as Read, Mark as Unread and Read All, for the chat list. Marks go through + * [ChatMarkedUnreadStore]; READ goes through the coordinator's [ChatCoordinator.markAsRead]. + * Mark as Unread sends nothing. + */ +class ChatReadActions internal constructor( + private val marks: ChatMarkedUnreadStore, + private val advanceRead: suspend (ChatId) -> Unit, +) { + + @Inject + constructor(coordinator: ChatCoordinator, marks: ChatMarkedUnreadStore) : + this(marks, { chatId -> coordinator.markAsRead(chatId) }) + + suspend fun markUnread(chatId: ChatId) = marks.mark(chatId) + + suspend fun markRead(chat: ChatSummary) { + val change = chat.markReadChange() + if (change.clearMark) marks.clear(chat.metadata.chatId) + if (change.advance) advanceRead(chat.metadata.chatId) + } + + /** Applies the read toggle to [chats]: [ChatReadToggle.MarkRead] if any is unread. */ + suspend fun toggle(chats: List): ChatReadToggle { + val toggle = bulkReadToggle(chats.map { it.isUnread }) + coroutineScope { + chats.forEach { chat -> + launch { + when (toggle) { + ChatReadToggle.MarkRead -> markRead(chat) + ChatReadToggle.MarkUnread -> markUnread(chat.metadata.chatId) + } + } + } + } + return toggle + } + + /** Read All over [main], the main list. Returns what it did. */ + suspend fun readAll(main: List): ChatReadAllPlan { + val plan = readAllPlan(main) + marks.clear(plan.clearMarks) + coroutineScope { plan.advance.forEach { launch { advanceRead(it) } } } + return plan + } +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt new file mode 100644 index 0000000000..2b321183dd --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt @@ -0,0 +1,65 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId + +/** The one read action a chat offers in the slot its swipe and select mode share. */ +enum class ChatReadToggle { MarkRead, MarkUnread } + +/** Mark as Read when the chat is unread (messages or a mark), Mark as Unread otherwise. */ +fun readToggleFor(isUnread: Boolean): ChatReadToggle = + if (isUnread) ChatReadToggle.MarkRead else ChatReadToggle.MarkUnread + +/** + * Select mode's read action: Mark as Read when any selected chat is unread, applied to every + * selected chat; otherwise Mark as Unread, applied to every selected chat. + */ +fun bulkReadToggle(selectedUnread: Iterable): ChatReadToggle = + readToggleFor(selectedUnread.any { it }) + +/** Select mode's mute action. */ +enum class ChatMuteToggle { Mute, Unmute } + +/** + * Unmute only when every selected chat is muted. Otherwise Mute, which opens the mute sheet once + * and applies its duration to every selected chat, ones already muted included. + */ +fun bulkMuteToggle(selectedMuted: Collection): ChatMuteToggle = + if (selectedMuted.isNotEmpty() && selectedMuted.all { it }) ChatMuteToggle.Unmute else ChatMuteToggle.Mute + +/** + * What Mark as Read does to one chat. [advance]: it has unread messages, so READ moves to the + * latest message, the same call opening the chat makes. [clearMark]: it is marked. A marked chat + * with nothing unread sends nothing. + */ +data class ChatReadChange(val advance: Boolean, val clearMark: Boolean) + +fun ChatSummary.markReadChange(): ChatReadChange = + ChatReadChange(advance = unreadCount != 0, clearMark = markedUnread) + +/** + * What Read All does to the main list: advance READ on every chat with unread messages (muted + * ones included) and clear every mark. Archived chats are not in the main list, so their marks stay. + * + * Read All is enabled exactly when this plan is not empty, so the menu and the confirm agree. + * + * @property count the chats whose READ advanced or mark cleared, each counted once: the `Count` + * on Chats Read All. + */ +data class ChatReadAllPlan(val advance: List, val clearMarks: List) { + val count: Int get() = (advance + clearMarks).toSet().size + val isEmpty: Boolean get() = advance.isEmpty() && clearMarks.isEmpty() +} + +fun readAllPlan(main: List): ChatReadAllPlan { + val changes = main.map { it.metadata.chatId to it.markReadChange() } + return ChatReadAllPlan( + advance = changes.filter { it.second.advance }.map { it.first }, + clearMarks = changes.filter { it.second.clearMark }.map { it.first }, + ) +} + +/** + * The count the bulk archive toast names: "Chat archived" for one, "%d chats archived" for more. + * Null when nothing was archived, so no toast is shown. + */ +fun archivedToastCount(archived: Int): Int? = archived.takeIf { it > 0 } diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt index 8aaea83eea..1a70f258fb 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -25,13 +25,24 @@ data class ChatState( // 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(), + // The chats the viewer marked as unread, read in that same combine for the same reason. + val markedUnread: Set = emptySet(), ) data class ChatSummary( val metadata: ChatMetadata, /** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */ val unreadCount: Int?, -) + /** The viewer marked the chat as unread. Kept apart from [unreadCount], which stays the real count. */ + val markedUnread: Boolean = false, +) { + /** + * Unread messages or a mark. The one test every unread count uses (the Chats tab badge, the + * Unread chip, Read All), so a marked chat counts once wherever it counts. + */ + val isUnread: Boolean + get() = unreadCount != 0 || markedUnread +} data class ActiveTypist( val userId: ID, diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 2c3948d6f2..9a5a67718a 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatMarkedUnreadStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.HostLobbies @@ -24,6 +25,7 @@ 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.RealChatMarkedUnreadStore import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.flipcash.app.persistence.sources.GroupKeyEnvelopeDataSource @@ -73,6 +75,12 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore + @Binds + @Singleton + abstract fun bindChatMarkedUnreadStore( + impl: RealChatMarkedUnreadStore + ): ChatMarkedUnreadStore + @Binds internal abstract fun bindLobbyRequestStore( impl: DataStoreLobbyRequestStore diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 300ce54952..aad9c465b8 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.AppForeground import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatMarkedUnreadStore import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.PaymentRequests @@ -110,6 +111,7 @@ class RealChatCoordinator @Inject constructor( private val hostInvites: HostInvites? = null, private val groupKeyEnvelopes: GroupKeyEnvelopeStore? = null, private val appForeground: AppForeground = AppForeground.None, + private val markedUnreadStore: ChatMarkedUnreadStore = ChatMarkedUnreadStore.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -260,6 +262,17 @@ class RealChatCoordinator @Inject constructor( .launchIn(scope) } + /** + * Opening a chat clears its unread mark. Every way into a chat (a row, a notification, a link) + * makes the chat screen call this, so this is the one place the mark is cleared on open. + * [onStart] restores a backgrounded chat through the delegate directly: returning to a chat + * the viewer already had open is not opening it again. + */ + override fun setActiveChatId(chatId: ChatId?) { + messagingDelegate.setActiveChatId(chatId) + if (chatId != null) scope.launch { markedUnreadStore.clear(chatId) } + } + /** * Also drops the backgrounded stash, which [onStop] fills and [onStart] restores from. A chat * screen destroyed while the app is away is gone by the time the app returns; without this it @@ -342,6 +355,8 @@ class RealChatCoordinator @Inject constructor( // record is left behind, harmlessly, because the chat is no longer a member chat and // so is in neither feed. archiveStore.unarchive(chatId) + // And its unread mark, for the same reason. + markedUnreadStore.clear(chatId) // The stored requests went with the membership change; this is the in-memory copy. // A removal or remote leave has no path here, and its cards stay until the session ends. paymentRequests?.forget(chatId) @@ -451,6 +466,7 @@ class RealChatCoordinator @Inject constructor( // the same file — and account deletion is the one path that is meant to erase them. draftStore.clearAll() archiveStore.clearAll() + markedUnreadStore.clearAll() paymentRequests?.clearStored() // Wrapped chat keys, stored per account like the rows above. groupKeyEnvelopes?.clear() diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt new file mode 100644 index 0000000000..f4d7c53b0d --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/RealChatMarkedUnreadStore.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatMarkedUnreadDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatMarkedUnreadStore +import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** [ChatMarkedUnreadStore] over the `chat_marked_unread` table. */ +@Singleton +class RealChatMarkedUnreadStore internal constructor( + private val dataSource: ChatMarkedUnreadDataSource, + private val now: () -> Long, +) : ChatMarkedUnreadStore { + + @Inject + constructor(dataSource: ChatMarkedUnreadDataSource) : this(dataSource, System::currentTimeMillis) + + override fun observeMarked(): Flow> = + dataSource.observeIds().map { hexes -> hexes.mapTo(HashSet()) { ChatId(it) } } + + override suspend fun mark(chatId: ChatId) = dataSource.mark(chatId.hex, now()) + + override suspend fun clear(chatId: ChatId) = dataSource.clear(chatId.hex) + + override suspend fun clear(chatIds: Collection) = dataSource.clear(chatIds.map { it.hex }) + + override suspend fun clearAll() = dataSource.clearAll() + + // Matches ChatEntityMapper.chatIdHex, as RealChatArchiveStore does. + private val ChatId.hex: String + get() = bytes.toList().hexEncodedString() +} diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 4dd856055c..d9aa337b6e 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/commonMain/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.ChatMarkedUnreadStore import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -84,6 +85,7 @@ class FeedSyncDelegate @Inject constructor( private val userManager: UserManager, private val messagingController: ChatMessagingController, private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, + private val markedUnreadStore: ChatMarkedUnreadStore = ChatMarkedUnreadStore.None, private val feedWriter: ChatFeedWriter = ChatFeedWriter(metadataDataSource, memberDataSource, messageDataSource), ) : FeedOperations { @@ -210,12 +212,12 @@ class FeedSyncDelegate @Inject constructor( .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> val count = unreadCount(metadata, selfId) { id -> state.readStampAt(metadata.chatId, id) } - ChatSummary(metadata = metadata, unreadCount = count) + ChatSummary(metadata = metadata, unreadCount = count, markedUnread = metadata.chatId in state.markedUnread) } } override fun observeUnreadConversations(vararg chatTypes: ChatType): Flow { - return feed(*chatTypes).map { summaries -> summaries.count { it.unreadCount != 0 } } + return feed(*chatTypes).map { summaries -> summaries.count { it.isUnread } } } override fun feedPaged(vararg chatTypes: ChatType): Flow> { @@ -294,9 +296,10 @@ class FeedSyncDelegate @Inject constructor( .onStart { emit(emptyList()) } .distinctUntilChanged(), archiveStore.observeArchived(), - ) { metadataEntities, membersByChat, _, archived -> - buildFeedFromDb(metadataEntities, membersByChat) to archived - }.onEach { (built, archived) -> + markedUnreadStore.observeMarked(), + ) { metadataEntities, membersByChat, _, archived, marked -> + Triple(buildFeedFromDb(metadataEntities, membersByChat), archived, marked) + }.onEach { (built, archived, marked) -> val (feed, readStamps) = built val staged = drainStagedStamps() stateHolder.update { @@ -305,6 +308,7 @@ class FeedSyncDelegate @Inject constructor( readStamps = readStamps, fetchedReadStamps = if (staged.isEmpty()) it.fetchedReadStamps else it.fetchedReadStamps + staged, archived = archived, + markedUnread = marked, ) } // Until the first sync settles, the reconcile looks the stamps up itself, bounded and diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 0cdb0acab1..c8e13595db 100644 --- a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -51,6 +51,8 @@ class ChatCoordinatorTeardownTest { private lateinit var memberDataSource: ChatMemberDataSource private lateinit var draftStore: ChatDraftStore private lateinit var archiveStore: ChatArchiveStore + private lateinit var markedUnreadStore: ChatMarkedUnreadStore + private val scheduler = TestCoroutineScheduler() private lateinit var groupFeedDelegate: GroupFeedDelegate private lateinit var hostInvites: HostInvites private lateinit var groupKeyEnvelopes: com.flipcash.services.chat.GroupKeyEnvelopeStore @@ -74,6 +76,7 @@ class ChatCoordinatorTeardownTest { memberDataSource = mockk(relaxed = true) draftStore = mockk(relaxed = true) archiveStore = mockk(relaxed = true) + markedUnreadStore = mockk(relaxed = true) groupFeedDelegate = mockk(relaxed = true) hostInvites = mockk(relaxed = true) groupKeyEnvelopes = mockk(relaxed = true) @@ -124,10 +127,11 @@ class ChatCoordinatorTeardownTest { draftStore = draftStore, userManager = userManager, networkObserver = mockk(relaxed = true), - dispatchers = TestDispatchers(TestCoroutineScheduler()), + dispatchers = TestDispatchers(scheduler), groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), archiveStore = archiveStore, + markedUnreadStore = markedUnreadStore, hostInvites = hostInvites, groupKeyEnvelopes = groupKeyEnvelopes, ) @@ -218,4 +222,54 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { archiveStore.unarchive(any()) } } + + // Test case 8. Like the archive set: the database is per-account, so erasure wipes the marks. + @Test + fun `clearCache drops the unread marks, teardown keeps them`() = runTest { + coordinator.teardown() + coVerify(exactly = 0) { markedUnreadStore.clearAll() } + + coordinator.clearCache() + coVerify(exactly = 1) { markedUnreadStore.clearAll() } + } + + // Test case 7. + @Test + fun `a successful leave clears the unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.success(Unit) + + coordinator.leave(chatId) + + coVerify(exactly = 1) { markedUnreadStore.clear(chatId) } + } + + @Test + fun `a failed leave keeps the unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.failure(RuntimeException("nope")) + + coordinator.leave(chatId) + + coVerify(exactly = 0) { markedUnreadStore.clear(any()) } + } + + // Test case 4: every way into a chat makes the chat screen call setActiveChatId. + @Test + fun `opening a chat clears its unread mark`() = runTest { + val chatId = ChatId("aabbccdd") + + coordinator.setActiveChatId(chatId) + scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { markedUnreadStore.clear(chatId) } + } + + @Test + fun `releasing the active chat clears nothing`() = runTest { + coordinator.setActiveChatId(null) + scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { markedUnreadStore.clear(any()) } + } } diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt new file mode 100644 index 0000000000..c1213b6c99 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatMarkedUnreadFeedTest.kt @@ -0,0 +1,205 @@ +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.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.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The unread mark rides the same combine as archive: a marked chat reads as unread in the feed and + * the tab badge, and its real count still shows when it has one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatMarkedUnreadFeedTest { + + 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)) + ), + ) + + private val selfRead = selfUnread.copy( + pointers = listOf( + MessagePointer(PointerType.READ, selfId, value = 2, 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>, + val marked: MutableStateFlow>, + ) + + private fun TestScope.fixture(initiallyArchived: Set = emptySet(), initiallyMarked: Set = emptySet()): Fixture { + val entities = listOf(entity(tipHex, ChatType.TIP_DM), entity(groupHex, ChatType.GROUP)) + val members = mapOf(tipHex to listOf(selfRead, 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 marked = MutableStateFlow(initiallyMarked) + val markStore = object : ChatMarkedUnreadStore by ChatMarkedUnreadStore.None { + override fun observeMarked() = marked + } + + 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, + markedUnreadStore = markStore, + ) + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + return Fixture(delegate, archived, marked) + } + + private val listTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP) + private val tip = ChatId(tipHex) + private val group = ChatId(groupHex) + + private suspend fun FeedSyncDelegate.summary(id: ChatId) = + feed(*listTypes).first().single { it.metadata.chatId == id } + + // Test case 1: the read tip DM, once marked, is unread with no count and lifts the badge by one. + @Test + fun `marking a read chat makes it unread with no count and adds one to the badge`() = runTest { + val f = fixture() + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + assertFalse(f.delegate.summary(tip).isUnread) + + f.marked.value = setOf(tip) + runCurrent() + + val summary = f.delegate.summary(tip) + assertEquals(0, summary.unreadCount) + assertTrue(summary.markedUnread) + assertTrue(summary.isUnread) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } + + // Test case 5: the group has one unread message and a mark; the count shows, the badge counts it once. + @Test + fun `a marked chat with unread messages keeps its count and counts once`() = runTest { + val f = fixture(initiallyMarked = setOf(group)) + val summary = f.delegate.summary(group) + assertEquals(1, summary.unreadCount) + assertTrue(summary.markedUnread) + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + assertEquals(1, f.delegate.observeUnreadChatListCount(flowOf(emptyMap())) { selfId }.first()) + } + + // Test case 6: archive and unarchive leave the mark alone. + @Test + fun `a marked chat stays marked through archive and unarchive`() = runTest { + val f = fixture(initiallyMarked = setOf(tip)) + + f.archived.value = setOf(tip) + runCurrent() + assertTrue(f.delegate.archivedFeed(*listTypes).first().single { it.metadata.chatId == tip }.markedUnread) + // An archived chat leaves the badge, mark or not. + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + + f.archived.value = emptySet() + runCurrent() + assertTrue(f.delegate.summary(tip).markedUnread) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } +} diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt new file mode 100644 index 0000000000..13cc6861a9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatReadActionsTest.kt @@ -0,0 +1,157 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** The chat-list read rules and the actions that apply them. Numbers are the spec's test cases. */ +class ChatReadActionsTest { + + private class FakeMarks(initial: Set = emptySet()) : ChatMarkedUnreadStore { + val marked = MutableStateFlow(initial) + override fun observeMarked(): Flow> = marked + override suspend fun mark(chatId: ChatId) { marked.value += chatId } + override suspend fun clear(chatId: ChatId) { marked.value -= chatId } + override suspend fun clear(chatIds: Collection) { marked.value -= chatIds.toSet() } + override suspend fun clearAll() { marked.value = emptySet() } + } + + private val a = ChatId("aa00") + private val b = ChatId("bb00") + private val c = ChatId("cc00") + private val e = ChatId("ee00") + + private fun chat(id: ChatId, unread: Int?, marked: Boolean = false) = ChatSummary( + metadata = ChatMetadata( + chatId = id, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + ), + unreadCount = unread, + markedUnread = marked, + ) + + private val advanced = mutableListOf() + private fun actions(marks: FakeMarks) = ChatReadActions(marks) { advanced += it } + + // 1 + @Test + fun `mark as unread on a read chat marks it and sends nothing`() = runTest { + val marks = FakeMarks() + assertEquals(ChatReadToggle.MarkUnread, readToggleFor(chat(c, 0).isUnread)) + actions(marks).markUnread(c) + assertEquals(setOf(c), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 2 + @Test + fun `mark as read on a marked chat with nothing unread clears the mark and sends nothing`() = runTest { + val marks = FakeMarks(setOf(b)) + val chat = chat(b, 0, marked = true) + assertEquals(ChatReadToggle.MarkRead, readToggleFor(chat.isUnread)) + actions(marks).markRead(chat) + assertEquals(emptySet(), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 3 + @Test + fun `a chat with unread messages offers mark as read, which advances READ`() = runTest { + val chat = chat(a, 3) + assertEquals(ChatReadToggle.MarkRead, readToggleFor(chat.isUnread)) + actions(FakeMarks()).markRead(chat) + assertEquals(listOf(a), advanced) + } + + @Test + fun `an unknown unread count reads as unread and advances`() = runTest { + val chat = chat(a, null) + assertTrue(chat.isUnread) + actions(FakeMarks()).markRead(chat) + assertEquals(listOf(a), advanced) + } + + // 9 + @Test + fun `read all advances unread, clears marks, leaves read chats and archived marks`() = runTest { + val d = ChatId("dd00") + val marks = FakeMarks(setOf(b, d)) + val main = listOf(chat(a, 2), chat(b, 0, marked = true), chat(c, 0)) + + val plan = actions(marks).readAll(main) + + assertEquals(listOf(a), advanced) + assertEquals(setOf(d), marks.marked.value) + assertEquals(2, plan.count) + } + + @Test + fun `read all counts a chat that is both unread and marked once`() { + assertEquals(1, readAllPlan(listOf(chat(a, 2, marked = true))).count) + } + + // 10 + @Test + fun `read all has nothing to do when nothing in the main list is unread`() { + assertTrue(readAllPlan(listOf(chat(c, 0), chat(e, 0))).isEmpty) + assertFalse(readAllPlan(listOf(chat(c, 0), chat(b, 0, marked = true))).isEmpty) + } + + // 12 + @Test + fun `selection with an unread chat offers mark as read and reads both`() = runTest { + val marks = FakeMarks() + val toggle = actions(marks).toggle(listOf(chat(a, 1), chat(c, 0))) + assertEquals(ChatReadToggle.MarkRead, toggle) + assertEquals(listOf(a), advanced) + assertEquals(emptySet(), marks.marked.value) + } + + @Test + fun `selection mark as read clears a selected mark too`() = runTest { + val marks = FakeMarks(setOf(b)) + actions(marks).toggle(listOf(chat(b, 0, marked = true), chat(c, 0))) + assertEquals(emptySet(), marks.marked.value) + } + + // 13 + @Test + fun `selection of read chats offers mark as unread and marks both`() = runTest { + val marks = FakeMarks() + val toggle = actions(marks).toggle(listOf(chat(c, 0), chat(e, 0))) + assertEquals(ChatReadToggle.MarkUnread, toggle) + assertEquals(setOf(c, e), marks.marked.value) + assertEquals(emptyList(), advanced) + } + + // 14 (the sheet and the duration are the screen's; this is the label) + @Test + fun `a mixed muted selection offers mute`() { + assertEquals(ChatMuteToggle.Mute, bulkMuteToggle(listOf(true, false))) + assertEquals(ChatMuteToggle.Mute, bulkMuteToggle(emptyList())) + } + + // 15 + @Test + fun `an all-muted selection offers unmute`() { + assertEquals(ChatMuteToggle.Unmute, bulkMuteToggle(listOf(true, true))) + } + + // 16 (the label count) + @Test + fun `the archive toast count is the number archived`() { + assertEquals(2, archivedToastCount(2)) + assertEquals(null, archivedToastCount(0)) + } +} From ee43a216a46ab29fccab8ceccbb392948cc2ee9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 12:54:44 -0500 Subject: [PATCH 03/16] feat(analytics): Chat Marked Unread, Chat Marked Read and Chats Read All --- .../SharedCoreKit/AnalyticsEvents.swift | 1 + libs/analytics-events/EVENTS.md | 36 +++++++++++++++++ libs/analytics-events/events.toml | 36 +++++++++++++++++ .../com/flipcash/analytics/ChatEventsTest.kt | 39 +++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index e0399f5028..de0347177b 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -10,6 +10,7 @@ public typealias TrackedEvent = SharedCore.AnalyticsEvent // `Amount` are renamed here so they don't read as SwiftUI's `Button` and `@State`, or as // one of the app's money types. public typealias ChatType = SharedCore.ChatType +public typealias ChatReadSource = SharedCore.ChatReadSource public typealias EventState = SharedCore.State public typealias AddMoneySource = SharedCore.AddMoneySource public typealias AddMoneyMethod = SharedCore.AddMoneyMethod diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 8dc934fb50..08d426a977 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -52,6 +52,32 @@ app has no value for it. | State | list | no | [State](#state) | | Error | text | yes | | +### Chat Marked Unread + +The viewer marked a chat as unread from the chat list. Stored on the device only; nothing is sent to the server. Select mode sends one per selected chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Source | list | no | [ChatReadSource](#chatreadsource) | + +### Chat Marked Read + +The viewer marked a chat as read from the chat list. Select mode sends one per selected chat. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Source | list | no | [ChatReadSource](#chatreadsource) | + +### Chats Read All + +The viewer confirmed Read All on the chat list. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Count | count | no | | + ## Group Group chats. `Member Count` is the member count before the action, from what the screen already holds. `Gate Mint` is left out when the group has no token gate. `Error` is the RPC's result name as the proto spells it, such as RulesNotSatisfied, or Network for a transport failure. @@ -879,6 +905,16 @@ The `Duration` property on Chat Muted: the mute option picked. Both apps offer t | 1 Week | `ONE_WEEK` | | | Always | `ALWAYS` | | +### ChatReadSource + +The `Source` property on Chat Marked Unread and Chat Marked Read: where on the chat list the action was taken. + +| Value sent | Name in code | Drift | +|---|---|---| +| Swipe | `SWIPE` | | +| Menu | `MENU` | Android doesn't send this source: a long press on Android enters select mode, so its actions go out as Selection. | +| Selection | `SELECTION` | | + ### SilenceDuration The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always. diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 84e2c4add2..bad5049beb 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -141,6 +141,13 @@ EIGHT_HOURS = "8 Hours" ONE_WEEK = "1 Week" ALWAYS = "Always" +[lists.ChatReadSource] +description = "The `Source` property on Chat Marked Unread and Chat Marked Read: where on the chat list the action was taken." +[lists.ChatReadSource.values] +SWIPE = "Swipe" +MENU = { value = "Menu", drift = "Android doesn't send this source: a long press on Android enters select mode, so its actions go out as Selection." } +SELECTION = "Selection" + [lists.SilenceDuration] description = "The `Duration` property on Group: Member Silenced: the silence option picked. Both apps offer these four. The last reads Forever here, where MuteDuration reads Always." [lists.SilenceDuration.values] @@ -367,6 +374,35 @@ properties = [ { name = "Error", type = "text", optional = true }, ] +[[event]] +domain = "Chat" +name = "Chat Marked Unread" +builder = "markedUnread" +description = "The viewer marked a chat as unread from the chat list. Stored on the device only; nothing is sent to the server. Select mode sends one per selected chat." +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Source", type = "ChatReadSource" }, +] + +[[event]] +domain = "Chat" +name = "Chat Marked Read" +builder = "markedRead" +description = "The viewer marked a chat as read from the chat list. Select mode sends one per selected chat." +properties = [ + { name = "Chat Type", type = "ChatType" }, + { name = "Source", type = "ChatReadSource" }, +] + +[[event]] +domain = "Chat" +name = "Chats Read All" +builder = "readAll" +description = "The viewer confirmed Read All on the chat list." +properties = [ + { name = "Count", type = "count" }, +] + # Group [[event]] diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt index 67336b0087..765565e56d 100644 --- a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt @@ -108,4 +108,43 @@ class ChatEventsTest { ChatEvents.unmuted(ChatType.GROUP, State.SUCCESS, null), ) } + + @Test + fun chatMarkedUnreadCarriesTypeAndSource() { + assertEquals( + AnalyticsEvent( + "Chat Marked Unread", + mapOf("Chat Type" to PropertyValue.Text("Group"), "Source" to PropertyValue.Text("Swipe")), + ), + ChatEvents.markedUnread(ChatType.GROUP, ChatReadSource.SWIPE), + ) + } + + @Test + fun chatMarkedReadCarriesTypeAndSource() { + assertEquals( + AnalyticsEvent( + "Chat Marked Read", + mapOf("Chat Type" to PropertyValue.Text("Tip"), "Source" to PropertyValue.Text("Selection")), + ), + ChatEvents.markedRead(ChatType.TIP, ChatReadSource.SELECTION), + ) + } + + @Test + fun chatReadSourcesKeepTheirWireStrings() { + assertEquals( + listOf("Swipe", "Menu", "Selection"), + listOf(ChatReadSource.SWIPE, ChatReadSource.MENU, ChatReadSource.SELECTION) + .map { (ChatEvents.markedRead(ChatType.GROUP, it).properties.getValue("Source") as PropertyValue.Text).value }, + ) + } + + @Test + fun chatsReadAllCarriesTheCount() { + assertEquals( + AnalyticsEvent("Chats Read All", mapOf("Count" to PropertyValue.Number(2.0))), + ChatEvents.readAll(2), + ) + } } From 337b0eb9dbe5964bcdbed461fe78bf38fefbfdad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 12:57:28 -0500 Subject: [PATCH 04/16] feat(ui): leading swipe actions on SwipeActionRow and mark read/unread icons --- .../ui/components/SwipeActionRowTest.kt | 114 ++++++++++++++ .../getcode/ui/components/SwipeActionRow.kt | 149 ++++++++++++++++-- .../res/drawable/ic_mark_chat_read.xml | 15 ++ .../res/drawable/ic_mark_chat_unread.xml | 18 +++ 4 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt create mode 100644 ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml create mode 100644 ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml diff --git a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt new file mode 100644 index 0000000000..773220c37f --- /dev/null +++ b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt @@ -0,0 +1,114 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.unit.dp +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 + +/** + * The leading side added to [SwipeActionRow]: a full swipe right fires the leading action once + * and, for an action that resets, sends the row home; a row with only trailing actions does not + * move on a swipe right. + */ +@RunWith(RobolectricTestRunner::class) +class SwipeActionRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var leadingFired = 0 + private var trailingFired = 0 + + private fun show(withLeading: Boolean) { + composeTestRule.setContent { + DesignSystem { + Box(Modifier.width(360.dp)) { + SwipeActionRow( + actions = listOf( + SwipeAction(background = Color.Gray, onTriggered = { trailingFired++ }) {}, + ), + leadingActions = if (withLeading) { + listOf( + SwipeAction( + background = Color.Blue, + onTriggered = { leadingFired++ }, + resetOnDismiss = true, + ) {} + ) + } else { + emptyList() + }, + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + } + } + + private fun rowLeft() = composeTestRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().left + + @Test + fun `a full leading swipe fires once and the row comes home`() { + show(withLeading = true) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeRight(startX = left + 1f, endX = right) } + composeTestRule.waitForIdle() + + assertEquals(1, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + + // The device path: a short drag leaves the row revealed, then a fast full swipe fires it. + @Test + fun `a full leading swipe from the revealed row fires once and the row comes home`() { + show(withLeading = true) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 60.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + assert(rowLeft() > 0.dp) { "the short drag should leave the row revealed" } + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + } + + @Test + fun `a row with only trailing actions does not move on a swipe right`() { + show(withLeading = false) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeRight(startX = left + 1f, endX = right) } + composeTestRule.waitForIdle() + + assertEquals(0, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + + private companion object { + const val ROW = "row" + } +} diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 4a1ac308b1..4f27fe9d4a 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -1,13 +1,15 @@ package com.getcode.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,8 +23,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,21 +42,26 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import org.jetbrains.compose.resources.painterResource +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import com.getcode.theme.White50 -import androidx.compose.foundation.clickable -import kotlin.math.abs -import kotlinx.coroutines.launch import com.getcode.util.resources.Res import com.getcode.util.resources.ic_delete +import kotlin.math.abs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource /** * Describes a single action revealed behind the row content on swipe. @@ -75,7 +82,9 @@ class SwipeAction( val content: @Composable () -> Unit, ) -private enum class SwipeState { Settled, Revealed, Dismissed } +// Revealed and Dismissed are the trailing side (content moved left); the Start pair is the leading +// side (content moved right), present only when the row has leading actions. +private enum class SwipeState { Settled, Revealed, Dismissed, RevealedStart, DismissedStart } /** * Keeps at most one [SwipeActionRow] open among the rows given the same group: starting a swipe on @@ -135,6 +144,11 @@ fun SwipeActionRow( * rightmost position, grows from a circle into a rounded rectangle as the swipe * progresses, and is automatically triggered on a full swipe (dismiss). All other * actions remain fixed-size circles and are click-only. + * + * [leadingActions] mirror that on the other side: swiping right reveals them, the **last** one + * sits leftmost, grows on the swipe and fires on a full swipe, honouring its own + * [SwipeAction.resetOnDismiss]. A full leading swipe also plays the gesture haptic. Without + * leading actions the row swipes left only, exactly as before. */ @Composable fun SwipeActionRow( @@ -143,9 +157,10 @@ fun SwipeActionRow( stateKey: Any? = null, initiallyRevealed: Boolean = false, revealGroup: SwipeRevealGroup? = null, + leadingActions: List = emptyList(), content: @Composable () -> Unit, ) { - if (actions.isEmpty()) { + if (actions.isEmpty() && leadingActions.isEmpty()) { Box(modifier) { content() } return } @@ -160,12 +175,21 @@ fun SwipeActionRow( val totalRevealWidth = (minActionSize + actionPadding) * actions.size + actionPadding val totalRevealWidthPx = with(density) { totalRevealWidth.toPx() } - val dismissAction = actions.last() - val currentDismissCallback by rememberUpdatedState(dismissAction.onTriggered) - val currentDismissResets by rememberUpdatedState(dismissAction.resetOnDismiss) + val dismissAction = actions.lastOrNull() + val currentDismissCallback by rememberUpdatedState(dismissAction?.onTriggered ?: {}) + val currentDismissResets by rememberUpdatedState(dismissAction?.resetOnDismiss ?: false) var pendingResetCallback by remember(stateKey) { mutableStateOf(false) } + val leadingRevealWidthPx = with(density) { + ((minActionSize + actionPadding) * leadingActions.size + actionPadding).toPx() + } + val leadingDismissAction = leadingActions.lastOrNull() + val currentLeadingCallback by rememberUpdatedState(leadingDismissAction?.onTriggered ?: {}) + val currentLeadingResets by rememberUpdatedState(leadingDismissAction?.resetOnDismiss ?: false) + var pendingLeadingReset by remember(stateKey) { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + val initialState = if (initiallyRevealed) SwipeState.Revealed else SwipeState.Settled val state = remember(stateKey) { AnchoredDraggableState( @@ -174,6 +198,9 @@ fun SwipeActionRow( if (newValue == SwipeState.Dismissed && currentDismissResets) { pendingResetCallback = true false // reject settle, system animates back + } else if (newValue == SwipeState.DismissedStart && currentLeadingResets) { + pendingLeadingReset = true + false } else { true } @@ -201,9 +228,38 @@ fun SwipeActionRow( // the next swipe to write again without relaunching anything. LaunchedEffect(dragInteractions) { dragInteractions.interactions.collect { interaction -> - if (interaction is DragInteraction.Start) pendingResetCallback = false + if (interaction is DragInteraction.Start) { + pendingResetCallback = false + pendingLeadingReset = false + } + } + } + // The leading side's version of the reset above. The threshold is confirmed while the finger is + // still down, so the callback and haptic fire then; sending the row home at that point would + // cancel the drag and leave the settle that follows on RevealedStart. Instead the row goes home + // once nothing is dragging or animating it, which also covers a swipe begun from the revealed row. + LaunchedEffect(pendingLeadingReset) { + if (pendingLeadingReset) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + currentLeadingCallback() } } + val isDragged by dragInteractions.collectIsDraggedAsState() + // The drag's own settle can start just after this does and interrupt it, landing on + // RevealedStart, so each attempt re-reads the state and waits for that settle to finish. + LaunchedEffect(state) { + while (true) { + snapshotFlow { + pendingLeadingReset && !isDragged && !state.isAnimationRunning && state.offset > 0f + }.first { it } + try { + state.animateTo(SwipeState.Settled) + } catch (e: CancellationException) { + currentCoroutineContext().ensureActive() + } + } + } + if (revealGroup != null) { val fallbackKey = remember { Any() } val groupKey = stateKey ?: fallbackKey @@ -231,14 +287,26 @@ fun SwipeActionRow( if (state.currentValue == SwipeState.Dismissed) { currentDismissCallback() } + if (state.currentValue == SwipeState.DismissedStart) { + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + currentLeadingCallback() + } } - LaunchedEffect(rowWidthPx, totalRevealWidthPx) { + val hasTrailing = actions.isNotEmpty() + val hasLeading = leadingActions.isNotEmpty() + LaunchedEffect(rowWidthPx, totalRevealWidthPx, leadingRevealWidthPx, hasTrailing, hasLeading) { if (rowWidthPx > 0f) { state.updateAnchors(DraggableAnchors { SwipeState.Settled at 0f - SwipeState.Revealed at -totalRevealWidthPx - SwipeState.Dismissed at -rowWidthPx + if (hasTrailing) { + SwipeState.Revealed at -totalRevealWidthPx + SwipeState.Dismissed at -rowWidthPx + } + if (hasLeading) { + SwipeState.RevealedStart at leadingRevealWidthPx + SwipeState.DismissedStart at rowWidthPx + } }) } } @@ -288,6 +356,8 @@ fun SwipeActionRow( } layout(constraints.maxWidth, constraints.maxHeight) { + // Hidden, and so not tappable, while the leading side is open. + if (state.offset > 0f || n == 0) return@layout val yCenter = (constraints.maxHeight - actionH) / 2 var x = constraints.maxWidth - padPx @@ -303,6 +373,53 @@ fun SwipeActionRow( } } + if (hasLeading) { + Layout( + content = { + leadingActions.forEach { action -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .clickable { + action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } + }, + contentAlignment = Alignment.Center, + ) { + action.content() + } + } + }, + modifier = Modifier.matchParentSize(), + ) { measurables, constraints -> + // The trailing measure policy, mirrored: the last action is outermost (leftmost). + val offset = state.offset.coerceAtLeast(0f) + val padPx = actionPaddingDp.roundToPx() + val minPx = minActionSize.roundToPx() + val n = measurables.size + val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) + val fixedSlotWidth = minPx + padPx + val outerAvailable = (offset - padPx - (n - 1) * fixedSlotWidth).coerceAtLeast(0f) + val outerW = (outerAvailable.toInt() - padPx).coerceAtLeast(minPx) + val placeables = measurables.mapIndexed { index, measurable -> + measurable.measure(Constraints.fixed(if (index == n - 1) outerW else minPx, actionH)) + } + layout(constraints.maxWidth, constraints.maxHeight) { + if (offset <= 0f) return@layout + val yCenter = (constraints.maxHeight - actionH) / 2 + var x = padPx + placeables.last().place(x, yCenter) + x += placeables.last().width + for (i in n - 2 downTo 0) { + x += padPx + placeables[i].place(x, yCenter) + x += placeables[i].width + } + } + } + } + // Foreground content that slides Box( modifier = Modifier diff --git a/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml new file mode 100644 index 0000000000..31fc1a8cc1 --- /dev/null +++ b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_read.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml new file mode 100644 index 0000000000..7054405d32 --- /dev/null +++ b/ui/resources/src/androidMain/res/drawable/ic_mark_chat_unread.xml @@ -0,0 +1,18 @@ + + + + + From d336abfcf97990712103f1c8f0172f36780f0cdb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 13:18:27 -0500 Subject: [PATCH 05/16] feat(chats): mark unread, Read All and select mode on the chat list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leading swipe on a main or archived row marks the chat unread or read. Long press, or "…" > Select Chats, enters select mode with a contextual bar (read toggle, Mute/Unmute, Archive/Unarchive). "…" > Read All confirms, then advances READ in every unread main-list chat and clears its marks. Bulk Mute opens the mute sheet once through a new MuteChats route that reuses MuteChatSheet. Bulk Archive shows one toast whose Undo restores all. --- .../ui/navigation/AppScreenContent.kt | 2 + .../src/androidMain/res/values/strings.xml | 19 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 12 + .../features/chat-list/build.gradle.kts | 4 + .../tipping/internal/ChatListSelectionTest.kt | 346 ++++++++++++++++++ .../app/tipping/ArchivedChatsScreen.kt | 37 +- .../com/flipcash/app/tipping/ChatsScreen.kt | 88 +++-- .../internal/HiltArchivedChatsViewModel.kt | 2 + .../tipping/internal/HiltChatsViewModel.kt | 29 +- .../internal/components/ChatSelectionBar.kt | 266 ++++++++++++++ .../app/tipping/ArchivedChatsContent.kt | 41 ++- .../app/tipping/ChatsScreenContent.kt | 41 ++- .../internal/ArchivedChatsViewModel.kt | 57 ++- .../app/tipping/internal/ChatRowActions.kt | 105 ++++++ .../tipping/internal/ChatSelectionActions.kt | 26 ++ .../app/tipping/internal/ChatsViewModel.kt | 91 ++++- .../internal/components/ChatSelection.kt | 43 +++ .../tipping/internal/components/TipChatRow.kt | 63 +++- .../internal/ChatsViewModelArchiveTest.kt | 1 + .../internal/ChatsViewModelRatesTest.kt | 1 + .../flipcash/app/messenger/MuteChatScreen.kt | 22 ++ .../messenger/internal/MuteChatViewModel.kt | 8 + .../internal/MuteChatAnalyticsTest.kt | 21 ++ .../com/flipcash/app/analytics/Conversions.kt | 49 --- .../com/flipcash/app/analytics/ChatResult.kt | 51 +++ .../flipcash/shared/chat/ui/ChatListRow.kt | 11 +- .../shared/chat/ui/ChatSummaryMapping.kt | 1 + .../shared/chat/ui/ConversationReference.kt | 15 +- .../com/flipcash/shared/chat/ChatReadRules.kt | 10 + 29 files changed, 1359 insertions(+), 103 deletions(-) create mode 100644 apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt create mode 100644 apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt create mode 100644 apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt create mode 100644 apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt create mode 100644 apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt create mode 100644 apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.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 77d36fc1bb..b198e191da 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 @@ -52,6 +52,7 @@ import com.flipcash.app.messenger.CurrencySendScreen import com.flipcash.app.messenger.internal.screens.profile.GroupInviteScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen +import com.flipcash.app.messenger.MuteChatsScreen import com.flipcash.app.messenger.SilenceUserScreen import com.flipcash.app.messenger.ProfileScreen import com.flipcash.app.messenger.SharePickerScreen @@ -185,6 +186,7 @@ internal fun appEntryProvider( annotatedEntry { key -> ShareProfileToChatsScreen(key) } annotatedEntry { key -> ShareCurrencyToChatsScreen(key) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } + annotatedEntry { key -> MuteChatsScreen(key.chatIds, key.chatTypes) } annotatedEntry { key -> SilenceUserScreen(key.chatId, key.userId, key.firstName, key.groupTitle) } diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 6fff7cc420..e9f643dfd3 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1362,6 +1362,25 @@ Archive chat Unarchive chat + + Mark as Unread + Mark as Read + Read All + Select Chats + Mute + Unmute + Archive + Unarchive + Close + More + %1$d Selected + + Chat archived + %1$d chats archived + + Mark All Chats as Read? + Everyone in these chats will see that you\'ve read their messages + Enter Flipcash username Enter the Flipcash username of the person you want to chat with diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt index 14ad8923b9..7291732991 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt @@ -565,6 +565,18 @@ sealed interface AppRoute : NavKey, Parcelable { com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + /** + * The mute sheet for several chats at once, from the chat list's select mode: the duration + * picked applies to every one of [chatIds], including any already muted. [chatTypes] pairs + * with [chatIds] by index, for the per-chat mute events. Presented like [MuteChat]. + */ + @Serializable + @Parcelize + data class MuteChats(val chatIds: List, val chatTypes: List) : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet + /** * How long to silence [userId] in the group [chatId]. Carries the words the sheet shows * ([firstName], [groupTitle]) so it needs no lookup before it can draw. Presented like diff --git a/apps/flipcash/features/chat-list/build.gradle.kts b/apps/flipcash/features/chat-list/build.gradle.kts index 978e03ad2a..cff96fb2fa 100644 --- a/apps/flipcash/features/chat-list/build.gradle.kts +++ b/apps/flipcash/features/chat-list/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { commonMain.dependencies { implementation(project(":apps:flipcash:core")) implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:common-ui")) @@ -42,6 +43,7 @@ kotlin { androidMain.dependencies { // What flipcash.android.feature supplied before the move to KMP. implementation(libs.bundles.hilt) + implementation(project(":libs:messaging")) implementation(project.dependencies.platform(libs.compose.bom)) implementation(libs.bundles.compose) } @@ -49,6 +51,7 @@ kotlin { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.mockk) + implementation(project(":apps:flipcash:shared:analytics:test-fixtures")) } getByName("androidHostTest").dependencies { implementation(kotlin("test")) @@ -56,6 +59,7 @@ kotlin { implementation(libs.robolectric) implementation(project(":ui:resources:test-fixtures")) implementation(project(":libs:test-utils")) + implementation(project(":apps:flipcash:shared:analytics:test-fixtures")) } } } diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt new file mode 100644 index 0000000000..62dbc11874 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/ChatListSelectionTest.kt @@ -0,0 +1,346 @@ +package com.flipcash.app.tipping.internal + +import androidx.test.core.app.ApplicationProvider +import com.flipcash.analytics.ChatReadSource +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.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.ChatListProjection +import com.flipcash.shared.chat.ChatMarkedUnreadStore +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadActions +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Rate +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coVerify +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.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.time.Instant +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The chat list's row actions and select mode: entering and leaving it, what the bar offers, and + * the spec cases that live above the shared rules (Read All's Cancel, bulk Mute, bulk Archive with + * Undo, and Requests chats taking no read action). The rules themselves are tested in shared/chat. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChatListSelectionTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + @Before + fun setUp() = BottomBarManager.clear() + + @After + fun tearDown() = BottomBarManager.clear() + + private fun id(n: Int) = ChatId(ByteArray(16) { n.toByte() }.toList()) + + private fun summary(n: Int, type: ChatType = ChatType.TIP_DM, unread: Int = 0, marked: Boolean = false) = + ChatSummary( + metadata = ChatMetadata( + chatId = id(n), + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000L + n), + title = "chat $n", + latestEventSequence = 0, + ), + unreadCount = unread, + markedUnread = marked, + ) + + // --- select mode, through the reducer --- + + private fun reduce(state: ChatsViewModel.State, event: ChatsViewModel.Event) = + ChatsViewModel.updateStateForEvent(event)(state) + + private val unreadA = ConversationReference(chatId = id(1), unreadCount = 3) + private val readC = ConversationReference(chatId = id(3)) + private val readE = ConversationReference(chatId = id(5)) + + private val loaded = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated( + Loadable.Loaded(listOf(unreadA, readC, readE)), + ChatListProjection.Empty.copy(main = listOf(unreadA, readC, readE).map { it.chatId.toString() }), + ), + ) + + private fun ChatsViewModel.State.select(vararg ids: ChatId): ChatsViewModel.State = + ids.fold(reduce(this, ChatsViewModel.Event.SelectionStarted(ids.first()))) { state, chatId -> + if (chatId == ids.first()) state else reduce(state, ChatsViewModel.Event.SelectionToggled(chatId)) + } + + @Test + fun `a long press enters select mode with that chat selected`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(readC.chatId)) + assertTrue(selecting.isSelecting) + assertEquals(listOf(readC), selecting.selectedChats) + assertEquals(1, selecting.selectionActions.count) + } + + @Test + fun `Select Chats enters select mode with nothing selected`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(null)) + assertTrue(selecting.isSelecting) + assertEquals(0, selecting.selectionActions.count) + } + + @Test + fun `deselecting the last row leaves select mode`() { + val selecting = reduce(loaded, ChatsViewModel.Event.SelectionStarted(readC.chatId)) + val left = reduce(selecting, ChatsViewModel.Event.SelectionToggled(readC.chatId)) + assertFalse(left.isSelecting) + } + + // Spec case 12. + @Test + fun `an unread chat in the selection makes the toggle Mark as Read`() { + assertEquals(ChatReadToggle.MarkRead, loaded.select(unreadA.chatId, readC.chatId).selectionActions.read) + } + + // Spec case 13. + @Test + fun `an all-read selection makes the toggle Mark as Unread`() { + assertEquals(ChatReadToggle.MarkUnread, loaded.select(readC.chatId, readE.chatId).selectionActions.read) + } + + @Test + fun `Read All is disabled when nothing in the main list is unread`() { + val allRead = reduce( + loaded, + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(readC, readE)), ChatListProjection.Empty), + ) + assertFalse(allRead.canReadAll) + assertTrue(loaded.canReadAll) + val marked = readC.copy(markedUnread = true) + assertTrue( + reduce(loaded, ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(marked)), ChatListProjection.Empty)) + .canReadAll + ) + } + + // --- the view model, for the actions that leave select mode --- + + private val rowActions = mockk(relaxed = true) + + private fun TestScope.viewModel(main: List): HiltChatsViewModel { + val feeds = MutableStateFlow(ChatFeeds(main = main, archived = emptyList())) + val chatCoordinator = mockk(relaxed = true).also { + every { it.feedWithArchived(*anyVararg()) } returns feeds + every { it.currentFeedWithArchived(*anyVararg()) } returns feeds.value + every { it.observeSenderProfiles() } returns flowOf(emptyMap()) + } + every { rowActions.summaries(any()) } answers { + val ids = firstArg>() + main.filter { it.metadata.chatId in ids } + } + return HiltChatsViewModel( + exchange = mockk(relaxed = true).also { + every { it.observePreferredRate() } returns flowOf(Rate.oneToOne) + every { it.observeRates() } returns flowOf(emptyMap()) + }, + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + tokenCoordinator = mockk(relaxed = true).also { + every { it.tokens } returns flowOf(emptyList()) + every { it.cachedTokens() } returns emptyList() + }, + resources = FakeResourceHelper(R::class.java), + dispatchers = TestDispatchers(testScheduler), + archiveStore = ChatArchiveStore.None, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = rowActions, + ) + } + + // Spec case 11. + @Test + fun `cancelling Read All changes nothing`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(listOf(summary(1, unread = 2))) + vm.requestReadAll() + + val prompt = BottomBarManager.messages.value.single() + assertTrue(prompt.showCancel) + assertEquals("prompt_title_readAllChats", prompt.title) + // Cancel is the prompt's own button and closes it; nothing else runs. + BottomBarManager.clear() + advanceUntilIdle() + coVerify(exactly = 0) { rowActions.readAll() } + + vm.requestReadAll() + BottomBarManager.messages.value.single().actions.single().onClick() + advanceUntilIdle() + coVerify(exactly = 1) { rowActions.readAll() } + } + + // Spec case 14. + @Test + fun `bulk Mute opens one sheet for the whole selection`() = runTest(mainCoroutineRule.dispatcher) { + val muted = summary(1, type = ChatType.GROUP) + val audible = summary(2) + val vm = viewModel(listOf(muted, audible)) + advanceUntilIdle() + vm.startSelection(muted.metadata.chatId) + vm.toggleSelection(audible.metadata.chatId) + assertEquals(ChatMuteToggle.Mute, vm.stateFlow.value.selectionActions.mute) + + val sheet = vm.muteSelection() + + assertEquals(listOf(id(1), id(2)) to listOf(ChatType.GROUP, ChatType.TIP_DM), sheet) + assertFalse(vm.stateFlow.value.isSelecting) + } + + // Spec case 16. + @Test + fun `bulk Archive archives every selected chat and Undo restores them all`() = + runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel(listOf(summary(1), summary(2))) + advanceUntilIdle() + vm.startSelection(id(1)) + vm.toggleSelection(id(2)) + + val archived = vm.archiveSelection() + advanceUntilIdle() + + assertEquals(setOf(id(1), id(2)), archived.toSet()) + coVerify(exactly = 1) { rowActions.archive(match { it.toSet() == setOf(id(1), id(2)) }) } + assertFalse(vm.stateFlow.value.isSelecting) + + vm.unarchive(archived) + advanceUntilIdle() + coVerify(exactly = 1) { rowActions.unarchive(match { it.toSet() == setOf(id(1), id(2)) }) } + } + + @Test + fun `the archive toast reads Chat archived for one and counts more`() { + val resources = ApplicationProvider.getApplicationContext().resources + assertEquals("Chat archived", resources.getQuantityString(R.plurals.message_chatsArchived, 1, 1)) + assertEquals("2 chats archived", resources.getQuantityString(R.plurals.message_chatsArchived, 2, 2)) + } + + // --- ChatRowActions: what each path sends --- + + private val marks = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + + private fun actions(main: List): Pair { + val coordinator = mockk(relaxed = true).also { + every { it.currentFeedWithArchived(*anyVararg()) } returns ChatFeeds(main, emptyList()) + } + return ChatRowActions( + chatCoordinator = coordinator, + readActions = ChatReadActions(coordinator, marks), + archiveStore = ChatArchiveStore.None, + analytics = analytics, + ) to coordinator + } + + // Spec case 1: marks locally, sends nothing, reports the swipe. + @Test + fun `a swipe on a read chat marks it unread without an RPC`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(3))) + + actions.toggleRead(id(3), ChatReadSource.SWIPE) + + coVerify(exactly = 1) { marks.mark(id(3)) } + coVerify(exactly = 0) { coordinator.markAsRead(any()) } + assertEquals( + listOf(ChatEvents.markedUnread(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SWIPE)), + analytics.events, + ) + } + + // Spec case 3: the swipe offers Mark as Read on an unread chat and advances READ. + @Test + fun `a swipe on an unread chat advances READ`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(1, unread = 3))) + + actions.toggleRead(id(1), ChatReadSource.SWIPE) + + coVerify(exactly = 1) { coordinator.markAsRead(id(1)) } + coVerify(exactly = 0) { marks.mark(any()) } + assertEquals( + listOf(ChatEvents.markedRead(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SWIPE)), + analytics.events, + ) + } + + @Test + fun `select mode reports one event per selected chat`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, _) = actions(listOf(summary(3), summary(5, type = ChatType.GROUP))) + + actions.toggleRead(listOf(id(3), id(5))) + + assertEquals( + listOf( + ChatEvents.markedUnread(chatType = ChatType.TIP_DM.analytics, source = ChatReadSource.SELECTION), + ChatEvents.markedUnread(chatType = ChatType.GROUP.analytics, source = ChatReadSource.SELECTION), + ), + analytics.events, + ) + } + + // Spec case 9's count reaches the event. + @Test + fun `Read All reports how many chats it changed`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, _) = actions(listOf(summary(1, unread = 2), summary(2, marked = true), summary(3))) + + actions.readAll() + + assertEquals(listOf(ChatEvents.readAll(count = 2)), analytics.events) + } + + // Spec case 18: a chat still in Requests is in neither list, so no read action reaches it. + @Test + fun `a chat still in Requests takes no read action`() = runTest(mainCoroutineRule.dispatcher) { + val (actions, coordinator) = actions(listOf(summary(1))) + val requested = id(9) + + actions.toggleRead(requested, ChatReadSource.SWIPE) + actions.toggleRead(listOf(requested)) + + coVerify(exactly = 0) { marks.mark(any()) } + coVerify(exactly = 0) { coordinator.markAsRead(any()) } + assertTrue(analytics.events.isEmpty()) + assertNull(actions.summaries(listOf(requested)).firstOrNull()) + } +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt index d593115685..bc80b4b341 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -1,6 +1,9 @@ -// kmp-audit: hiltViewModel is Android-only; the screen content is ArchivedChatsContent in commonMain. +// kmp-audit: hiltViewModel and BackHandler are Android-only; the screen content is ArchivedChatsContent in commonMain. package com.flipcash.app.tipping +import com.flipcash.app.tipping.internal.components.ChatSelectionTopBar +import com.flipcash.app.tipping.internal.components.SelectionArchiveAction +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,17 +29,39 @@ fun ArchivedChatsScreen() { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current + // Close, system back, or deselecting the last row leaves select mode. + BackHandler(enabled = state.isSelecting) { viewModel.endSelection() } + ArchivedChatsContent( chats = state.chats.dataOrNull.orEmpty(), isLoaded = state.chats.isLoaded(), onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, onUnarchive = viewModel::unarchive, + selection = state.selection, + onStartSelection = viewModel::startSelection, + onToggleSelection = viewModel::toggleSelection, + onToggleRead = viewModel::toggleRead, topBar = { - AppBarWithTitle( - title = stringResource(Res.string.title_archived), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = { navigator.pop() }, - ) + ChatSelectionTopBar( + selectionActions = if (state.isSelecting) state.selectionActions else null, + archiveAction = SelectionArchiveAction.Unarchive, + onClose = viewModel::endSelection, + onRead = viewModel::readSelection, + onMute = { + viewModel.muteSelection()?.let { (ids, types) -> + navigator.push(AppRoute.Messaging.MuteChats(ids, types)) + } + }, + onUnmute = viewModel::unmuteSelection, + onArchive = viewModel::unarchiveSelection, + label = "archived list bar", + ) { + AppBarWithTitle( + title = stringResource(Res.string.title_archived), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + } }, ) } diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 41095c5c2e..7edc69854e 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,6 +1,12 @@ -// kmp-audit: hiltViewModel is Android-only; the screen content is ChatsScreenContent in commonMain. +// kmp-audit: hiltViewModel and BackHandler are Android-only; the screen content is ChatsScreenContent in commonMain. package com.flipcash.app.tipping +import androidx.activity.compose.BackHandler +import com.flipcash.app.tipping.internal.components.ChatListMoreButton +import com.flipcash.app.tipping.internal.components.ChatSelectionTopBar +import com.flipcash.app.tipping.internal.components.SelectionArchiveAction +import com.flipcash.services.models.chat.ChatId +import org.jetbrains.compose.resources.getPluralString import androidx.compose.material.SnackbarResult import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Archive @@ -15,7 +21,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.tipping.internal.HiltChatsViewModel import com.flipcash.core.resources.Res import com.flipcash.core.resources.action_undo -import com.flipcash.core.resources.message_chatArchived +import com.flipcash.core.resources.message_chatsArchived import com.flipcash.core.resources.title_tabChat import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults @@ -38,9 +44,25 @@ fun ChatsScreen() { // The root toast host, so the undo rises out of the tab bar on its glass. val toasts = LocalFloatingToastHost.current val scope = rememberCoroutineScope() - val archivedMessage = stringResource(Res.string.message_chatArchived) val undoLabel = stringResource(Res.string.action_undo) + // Archive with one Undo for everything it took: the swipe passes one chat, select mode several. + val archiveWithUndo: (List) -> Unit = { ids -> + if (toasts != null && ids.isNotEmpty()) { + scope.launch { + val result = toasts.show( + message = getPluralString(Res.plurals.message_chatsArchived, ids.size, ids.size), + icon = Icons.Outlined.Archive, + actionLabel = undoLabel, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(ids) + } + } + } + + // Close, system back, or deselecting the last row leaves select mode. + BackHandler(enabled = state.isSelecting) { viewModel.endSelection() } + ChatsScreenContent( state = state, onSelectFilter = viewModel::selectFilter, @@ -53,30 +75,48 @@ fun ChatsScreen() { onArchive = { chat -> viewModel.archive(chat.chatId) // The row leaves the list at once, so offer to put it back. - if (toasts != null) { - scope.launch { - val result = toasts.show( - message = archivedMessage, - icon = Icons.Outlined.Archive, - actionLabel = undoLabel, - ) - if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) - } - } + archiveWithUndo(listOf(chat.chatId)) }, + onToggleRead = { chat -> viewModel.toggleRead(chat.chatId) }, + onStartSelection = viewModel::startSelection, + onToggleSelection = viewModel::toggleSelection, topBar = { - AppBarWithTitle( - title = stringResource(Res.string.title_tabChat), - // Centred rather than flush-start: an empty leading slot reserves no width, so a - // Start title sits at the inset and reads as off-centre against the Add button. - titleAlignment = Alignment.CenterHorizontally, - // Node 9442:5779 — the only way to start a chat with someone who has never paid - // you. A pushed route rather than an in-place screen: this list is a tab home, and - // anything drawn inside it would leave the tab bar over the entry screen. - endContent = { - AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } + // Select mode takes the bar over, as a message selection does in a chat. + ChatSelectionTopBar( + selectionActions = if (state.isSelecting) state.selectionActions else null, + archiveAction = SelectionArchiveAction.Archive, + onClose = viewModel::endSelection, + onRead = viewModel::readSelection, + onMute = { + viewModel.muteSelection()?.let { (ids, types) -> + navigator.push(AppRoute.Messaging.MuteChats(ids, types)) + } }, - ) + onUnmute = viewModel::unmuteSelection, + onArchive = { archiveWithUndo(viewModel.archiveSelection()) }, + label = "chat list bar", + ) { + AppBarWithTitle( + title = stringResource(Res.string.title_tabChat), + // Centred rather than flush-start: an empty leading slot reserves no width, so a + // Start title sits at the inset and reads as off-centre against the Add button. + titleAlignment = Alignment.CenterHorizontally, + // Node 13201:108 — opposite Add and drawn the same way. + leadingContent = { + ChatListMoreButton( + canReadAll = state.canReadAll, + onSelectChats = { viewModel.startSelection() }, + onReadAll = viewModel::requestReadAll, + ) + }, + // Node 9442:5779 — the only way to start a chat with someone who has never paid + // you. A pushed route rather than an in-place screen: this list is a tab home, and + // anything drawn inside it would leave the tab bar over the entry screen. + endContent = { + AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } + }, + ) + } }, ) } diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt index c118e2e065..d8b55110de 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltArchivedChatsViewModel.kt @@ -18,6 +18,7 @@ internal class HiltArchivedChatsViewModel @Inject constructor( exchange: Exchange, chatCoordinator: ChatCoordinator, archiveStore: ChatArchiveStore, + rowActions: ChatRowActions, userManager: UserManager, tokenCoordinator: TokenCoordinator, resources: ResourceHelper, @@ -28,6 +29,7 @@ internal class HiltArchivedChatsViewModel @Inject constructor( exchange = exchange, chatCoordinator = chatCoordinator, archiveStore = archiveStore, + rowActions = rowActions, userManager = userManager, tokenCoordinator = tokenCoordinator, resources = resources, diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt index c053196da3..0d32a0146b 100644 --- a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/HiltChatsViewModel.kt @@ -1,5 +1,11 @@ package com.flipcash.app.tipping.internal +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.prompt_description_readAllChats +import com.flipcash.core.resources.prompt_title_readAllChats +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -13,16 +19,17 @@ import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -/** Hilt entry point for the shared [ChatsViewModel]. */ +/** Hilt entry point for the shared [ChatsViewModel], plus Read All's Android-only confirm prompt. */ @HiltViewModel internal class HiltChatsViewModel @Inject constructor( exchange: Exchange, chatCoordinator: ChatCoordinator, userManager: UserManager, tokenCoordinator: TokenCoordinator, - resources: ResourceHelper, + private val resources: ResourceHelper, dispatchers: DispatcherProvider, archiveStore: ChatArchiveStore, + rowActions: ChatRowActions, lobbyRequests: LobbyRequests, hostLobbies: HostLobbies, paymentRequests: PaymentRequests, @@ -34,7 +41,23 @@ internal class HiltChatsViewModel @Inject constructor( resources = resources, dispatchers = dispatchers, archiveStore = archiveStore, + rowActions = rowActions, lobbyRequests = lobbyRequests, hostLobbies = hostLobbies, paymentRequests = paymentRequests, -) +) { + + /** + * Read All asks first: it moves the READ pointer, which everyone in those chats sees. Cancel + * leaves everything as it was; Mark as Read runs [readAll]. Here rather than in [ChatsViewModel] + * because [BottomBarManager] is Android-only. + */ + fun requestReadAll() { + BottomBarManager.showMessage( + title = resources.getString(Res.string.prompt_title_readAllChats), + message = resources.getString(Res.string.prompt_description_readAllChats), + actions = listOf(BottomBarAction(text = resources.getString(Res.string.action_markAsRead)) { readAll() }), + showCancel = true, + ) + } +} diff --git a/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt new file mode 100644 index 0000000000..136828f2fa --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelectionBar.kt @@ -0,0 +1,266 @@ +// kmp-audit: AppBarWithTitle and CircularIconButton are Android-only; the rows and the read swipe are in commonMain. +package com.flipcash.app.tipping.internal.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Unarchive +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.tipping.internal.ChatSelectionActions +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_archive +import com.flipcash.core.resources.action_closeChatSelection +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.action_markAsUnread +import com.flipcash.core.resources.action_mute +import com.flipcash.core.resources.action_readAll +import com.flipcash.core.resources.action_selectChats +import com.flipcash.core.resources.action_unarchive +import com.flipcash.core.resources.action_unmute +import com.flipcash.core.resources.content_description_moreChatActions +import com.flipcash.core.resources.title_chatsSelected +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadToggle +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton +import com.getcode.util.resources.Res as UiRes +import com.getcode.util.resources.ic_mark_chat_read +import com.getcode.util.resources.ic_mark_chat_unread +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** The archive slot of the select-mode bar: Archive on the Chats list, Unarchive on Archived. */ +internal enum class SelectionArchiveAction { Archive, Unarchive } + +/** + * The list's title bar, or select mode's [ChatSelectionBar] in its place. Keyed on select mode + * only, so the bar fading out keeps the selection it showed rather than the emptied one that ended + * select mode. + */ +@Composable +internal fun ChatSelectionTopBar( + selectionActions: ChatSelectionActions?, + archiveAction: SelectionArchiveAction, + onClose: () -> Unit, + onRead: () -> Unit, + onMute: () -> Unit, + onUnmute: () -> Unit, + onArchive: () -> Unit, + label: String, + titleBar: @Composable () -> Unit, +) { + AnimatedContent( + targetState = selectionActions, + contentKey = { it != null }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = label, + ) { actions -> + if (actions != null) { + ChatSelectionBar( + actions = actions, + archiveAction = archiveAction, + onClose = onClose, + onRead = onRead, + onMute = onMute, + onUnmute = onUnmute, + onArchive = onArchive, + ) + } else { + titleBar() + } + } +} + +/** + * Select mode's contextual bar, in place of the title bar: close, the selected count, then the read + * toggle, mute and archive as icon buttons. Built like the in-chat message selection bar (leading + * control, title, icon actions in the end slot). The actions sit disabled with nothing selected, + * which is how Select Chats opens. + */ +@Composable +internal fun ChatSelectionBar( + actions: ChatSelectionActions, + archiveAction: SelectionArchiveAction, + onClose: () -> Unit, + onRead: () -> Unit, + onMute: () -> Unit, + onUnmute: () -> Unit, + onArchive: () -> Unit, + modifier: Modifier = Modifier, +) { + val enabled = actions.count > 0 + AppBarWithTitle( + modifier = modifier, + leftIcon = { + SelectionBarButton( + icon = Icons.Outlined.Close, + label = stringResource(Res.string.action_closeChatSelection), + testTag = "action_close_chat_selection", + onClick = onClose, + ) + }, + title = { + Text( + modifier = Modifier.testTag("chat_selection_count"), + text = stringResource(Res.string.title_chatsSelected, actions.count), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + rightContents = { + when (actions.read) { + ChatReadToggle.MarkRead -> SelectionBarButton( + painter = UiRes.drawable.ic_mark_chat_read, + label = stringResource(Res.string.action_markAsRead), + testTag = "action_mark_selection_read", + enabled = enabled, + onClick = onRead, + ) + ChatReadToggle.MarkUnread -> SelectionBarButton( + painter = UiRes.drawable.ic_mark_chat_unread, + label = stringResource(Res.string.action_markAsUnread), + testTag = "action_mark_selection_unread", + enabled = enabled, + onClick = onRead, + ) + } + when (actions.mute) { + ChatMuteToggle.Mute -> SelectionBarButton( + icon = Icons.Outlined.NotificationsOff, + label = stringResource(Res.string.action_mute), + testTag = "action_mute_selection", + enabled = enabled, + onClick = onMute, + ) + ChatMuteToggle.Unmute -> SelectionBarButton( + icon = Icons.Outlined.Notifications, + label = stringResource(Res.string.action_unmute), + testTag = "action_unmute_selection", + enabled = enabled, + onClick = onUnmute, + ) + } + when (archiveAction) { + SelectionArchiveAction.Archive -> SelectionBarButton( + icon = Icons.Outlined.Archive, + label = stringResource(Res.string.action_archive), + testTag = "action_archive_selection", + enabled = enabled, + onClick = onArchive, + ) + SelectionArchiveAction.Unarchive -> SelectionBarButton( + icon = Icons.Outlined.Unarchive, + label = stringResource(Res.string.action_unarchive), + testTag = "action_unarchive_selection", + enabled = enabled, + onClick = onArchive, + ) + } + }, + ) +} + +@Composable +private fun SelectionBarButton( + label: String, + testTag: String, + onClick: () -> Unit, + icon: ImageVector? = null, + painter: DrawableResource? = null, + enabled: Boolean = true, +) { + CircularIconButton(onClick = onClick, enabled = enabled, testTag = testTag) { size -> + val tint = if (enabled) Color.White else CodeTheme.colors.textSecondary + val iconModifier = Modifier.requiredSize(size) + if (icon != null) { + Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = iconModifier) + } else if (painter != null) { + Icon(painter = painterResource(painter), contentDescription = label, tint = tint, modifier = iconModifier) + } + } +} + +/** + * The "…" button on the Chats title bar's leading side, opposite `+` and drawn the same way, with + * its menu: Select Chats, and Read All (disabled when nothing in the main list is unread). The menu + * surface is the in-chat overflow's. + */ +@Composable +internal fun ChatListMoreButton( + canReadAll: Boolean, + onSelectChats: () -> Unit, + onReadAll: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + CircularIconButton(onClick = { expanded = true }, testTag = "action_chat_list_more") { size -> + Icon( + imageVector = Icons.Outlined.MoreHoriz, + contentDescription = stringResource(Res.string.content_description_moreChatActions), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + DropdownMenu( + expanded = expanded, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = CodeTheme.dimens.grid.x2), + onDismissRequest = { expanded = false }, + ) { + // The app's colors, as ChatTopBar's menu sets them; M3's defaults are dark on this surface. + val itemColors = MenuDefaults.itemColors( + textColor = CodeTheme.colors.textMain, + disabledTextColor = CodeTheme.colors.textSecondary, + ) + DropdownMenuItem( + modifier = Modifier.testTag("action_select_chats"), + colors = itemColors, + text = { Text(stringResource(Res.string.action_selectChats), style = CodeTheme.typography.textMedium) }, + onClick = { + expanded = false + onSelectChats() + }, + ) + DropdownMenuItem( + modifier = Modifier.testTag("action_read_all"), + colors = itemColors, + text = { Text(stringResource(Res.string.action_readAll), style = CodeTheme.typography.textMedium) }, + enabled = canReadAll, + onClick = { + expanded = false + onReadAll() + }, + ) + } + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt index ada9ae2955..daabed7463 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tipping +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import com.flipcash.app.tipping.internal.components.markReadSwipeAction import com.flipcash.core.resources.Res import com.flipcash.core.resources.content_description_unarchiveChat import com.flipcash.core.resources.title_noArchivedChats @@ -43,9 +46,15 @@ internal fun ArchivedChatsContent( onOpen: (ChatId) -> Unit, onUnarchive: (ChatId) -> Unit, topBar: @Composable () -> Unit, + // Select mode: the chats picked so far, or null outside it, and what the bar can do to them. + selection: Set? = null, + onStartSelection: (ChatId) -> Unit = {}, + onToggleSelection: (ChatId) -> Unit = {}, + onToggleRead: (ChatId) -> Unit = {}, ) { // One row open at a time, as on the Chats list. val revealGroup = rememberSwipeRevealGroup() + val haptics = LocalHapticFeedback.current CodeScaffold( topBar = topBar, @@ -80,13 +89,27 @@ internal fun ArchivedChatsContent( itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> UnarchiveSwipeRow( modifier = Modifier.animateItem(), + isUnread = chat.isUnread, + // Swipes are off while selecting. + swipeEnabled = selection == null, + onToggleRead = { onToggleRead(chat.chatId) }, 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) + TipChatRow( + chat = chat, + showDivider = index < chats.lastIndex, + selected = selection?.let { chat.chatId in it }, + onLongClick = { + if (selection == null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onStartSelection(chat.chatId) + } + }, + ) { + if (selection != null) onToggleSelection(chat.chatId) else onOpen(chat.chatId) } } } @@ -96,6 +119,9 @@ internal fun ArchivedChatsContent( @Composable private fun UnarchiveSwipeRow( + isUnread: Boolean, + swipeEnabled: Boolean, + onToggleRead: () -> Unit, onUnarchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, @@ -103,7 +129,13 @@ private fun UnarchiveSwipeRow( content: @Composable () -> Unit, ) { val label = stringResource(Res.string.content_description_unarchiveChat) + val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) + if (!swipeEnabled) { + Box(modifier) { content() } + return + } SwipeActionRow( + leadingActions = listOf(readAction), actions = listOf( SwipeAction( background = CodeTheme.colors.surfaceVariant, @@ -119,7 +151,10 @@ private fun UnarchiveSwipeRow( ), // 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 }) + customActions = listOf( + CustomAccessibilityAction(readLabel) { onToggleRead(); true }, + CustomAccessibilityAction(label) { onUnarchive(); true }, + ) }, stateKey = stateKey, revealGroup = revealGroup, diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index 4ad16825b2..0d5f2a5931 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -1,5 +1,7 @@ package com.flipcash.app.tipping +import com.flipcash.app.tipping.internal.components.markReadSwipeAction +import com.flipcash.services.models.chat.ChatId import com.flipcash.core.resources.Res import com.flipcash.core.resources.action_undo import com.flipcash.core.resources.content_description_archiveChat @@ -122,6 +124,11 @@ fun ChatsScreenContent( onMute: (ConversationReference) -> Unit, onArchive: (ConversationReference) -> Unit, topBar: @Composable () -> Unit, + // The leading swipe: Mark as Read or Mark as Unread, whichever the row offers. + onToggleRead: (ConversationReference) -> Unit = {}, + // Select mode: a long press (with that chat) or Select Chats (with none) starts it. + onStartSelection: (ChatId?) -> Unit = {}, + onToggleSelection: (ChatId) -> Unit = {}, ) { val chats = state.chats // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips @@ -270,7 +277,17 @@ fun ChatsScreenContent( } else -> tipChatItems( chats = visible, - onClick = onOpenChat, + selection = state.selection, + onClick = { chat -> + if (state.isSelecting) onToggleSelection(chat.chatId) else onOpenChat(chat) + }, + onLongClick = { chat -> + if (!state.isSelecting) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onStartSelection(chat.chatId) + } + }, + onToggleRead = onToggleRead, // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. onMute = onMute, @@ -338,7 +355,10 @@ private fun NoChatsYet(modifier: Modifier = Modifier) { private fun LazyListScope.tipChatItems( chats: List, + selection: Set?, onClick: (ConversationReference) -> Unit, + onLongClick: (ConversationReference) -> Unit, + onToggleRead: (ConversationReference) -> Unit, onMute: (ConversationReference) -> Unit, onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, @@ -351,6 +371,10 @@ private fun LazyListScope.tipChatItems( ChatSwipeRow( modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), isMuted = rememberIsMuted(chat.viewerState), + isUnread = chat.isUnread, + // Swipes are off while selecting. + swipeEnabled = selection == null, + onToggleRead = { onToggleRead(chat) }, onMute = { onMute(chat) }, onArchive = { onArchive(chat) }, stateKey = chat.chatId, @@ -359,6 +383,8 @@ private fun LazyListScope.tipChatItems( TipChatRow( chat = chat, showDivider = index < chats.lastIndex, + selected = selection?.let { chat.chatId in it }, + onLongClick = { onLongClick(chat) }, ) { onClick(chat) } @@ -367,7 +393,8 @@ private fun LazyListScope.tipChatItems( } /** - * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action + * One leading swipe action, Mark as Read or Mark as Unread (a full swipe fires it), and two + * trailing ones: 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. * @@ -382,6 +409,9 @@ private fun LazyListScope.tipChatItems( @Composable private fun ChatSwipeRow( isMuted: Boolean, + isUnread: Boolean, + swipeEnabled: Boolean, + onToggleRead: () -> Unit, onMute: () -> Unit, onArchive: () -> Unit, stateKey: Any, @@ -394,7 +424,13 @@ private fun ChatSwipeRow( else Res.string.content_description_muteChat ) val archiveLabel = stringResource(Res.string.content_description_archiveChat) + val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) + if (!swipeEnabled) { + Box(modifier) { content() } + return + } SwipeActionRow( + leadingActions = listOf(readAction), actions = listOf( SwipeAction( background = CodeTheme.colors.surfaceVariant, @@ -429,6 +465,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 { customActions = listOf( + CustomAccessibilityAction(readLabel) { onToggleRead(); true }, CustomAccessibilityAction(muteLabel) { onMute(); true }, CustomAccessibilityAction(archiveLabel) { onArchive(); true }, ) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index a3bc0568b5..223687b04d 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -6,6 +6,9 @@ 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.models.chat.ChatType +import com.flipcash.analytics.ChatReadSource +import com.flipcash.shared.chat.toggledSelection import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator @@ -34,6 +37,7 @@ internal open class ArchivedChatsViewModel @Inject constructor( dispatchers: DispatcherProvider, paymentRequests: PaymentRequests, hostLobbies: HostLobbies, + private val rowActions: ChatRowActions, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -41,16 +45,60 @@ internal open class ArchivedChatsViewModel @Inject constructor( data class State( val chats: Loadable> = Loadable.Loading(), - ) + // Select mode, as on the Chats list: the chats picked so far, or null outside it. + val selection: Set? = null, + ) { + val isSelecting: Boolean get() = selection != null + + val selectedChats: List + get() { + val ids = selection ?: return emptyList() + return chats.dataOrNull.orEmpty().filter { it.chatId in ids } + } + + val selectionActions: ChatSelectionActions get() = ChatSelectionActions.of(selectedChats) + } sealed interface Event { data class ChatsUpdated(val chats: Loadable>) : Event + data class SelectionStarted(val chatId: ChatId) : Event + data class SelectionToggled(val chatId: ChatId) : Event + data object SelectionEnded : Event } fun unarchive(chatId: ChatId) { viewModelScope.launch { archiveStore.unarchive(chatId) } } + fun toggleRead(chatId: ChatId) { + viewModelScope.launch { rowActions.toggleRead(chatId, ChatReadSource.SWIPE) } + } + + fun startSelection(chatId: ChatId) = dispatchEvent(Event.SelectionStarted(chatId)) + + fun toggleSelection(chatId: ChatId) = dispatchEvent(Event.SelectionToggled(chatId)) + + fun endSelection() = dispatchEvent(Event.SelectionEnded) + + fun readSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.toggleRead(ids) } } + + fun unmuteSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unmute(ids) } } + + fun muteSelection(): Pair, List>? { + var target: Pair, List>? = null + actOnSelection { ids -> target = rowActions.chatTypes(ids) } + return target + } + + /** Select mode's Unarchive: the selected chats go back to the main list. */ + fun unarchiveSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unarchive(ids) } } + + private inline fun actOnSelection(action: (List) -> Unit) { + val ids = stateFlow.value.selectedChats.map { it.chatId } + if (ids.isNotEmpty()) action(ids) + endSelection() + } + init { combine( chatCoordinator.archivedChatListFeed(), @@ -73,9 +121,14 @@ internal open class ArchivedChatsViewModel @Inject constructor( } 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.SelectionStarted -> { state -> state.copy(selection = setOf(event.chatId)) } + is Event.SelectionToggled -> { state -> + state.copy(selection = state.selection?.toggledSelection(event.chatId)) + } + Event.SelectionEnded -> { state -> state.copy(selection = null) } } } } diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt new file mode 100644 index 0000000000..b494bae493 --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatRowActions.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.analytics.ChatReadSource +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.chatResult +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatReadActions +import com.flipcash.shared.chat.ChatReadAllPlan +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.currentChatListFeeds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * What a chat list row, select mode and the "…" menu do, shared by the Chats and Archived lists: + * the read actions through [ChatReadActions], archive, bulk unmute, and the analytics each sends. + * + * Chats are looked up in the coordinator's feeds rather than taken from the rows, so a decision + * (unread or not, muted or not) reads the same state the rows were drawn from. + */ +class ChatRowActions @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val readActions: ChatReadActions, + private val archiveStore: ChatArchiveStore, + private val analytics: FlipcashAnalytics, +) { + + /** The main list's and the archived list's summaries for [ids], in [ids] order. */ + fun summaries(ids: Collection): List { + val feeds = chatCoordinator.currentChatListFeeds() + val all = (feeds?.main.orEmpty() + feeds?.archived.orEmpty()).associateBy { it.metadata.chatId } + return ids.mapNotNull { all[it] } + } + + /** The main list, as Read All sees it. */ + fun mainList(): List = chatCoordinator.currentChatListFeeds()?.main.orEmpty() + + /** The leading swipe: Mark as Read on an unread chat, Mark as Unread otherwise. */ + suspend fun toggleRead(chatId: ChatId, source: ChatReadSource) { + val chat = summaries(listOf(chatId)).firstOrNull() ?: return + if (chat.isUnread) { + readActions.markRead(chat) + analytics.track(ChatEvents.markedRead(chatType = chat.metadata.type.analytics, source = source)) + } else { + readActions.markUnread(chatId) + analytics.track(ChatEvents.markedUnread(chatType = chat.metadata.type.analytics, source = source)) + } + } + + /** Select mode's read toggle over [ids]: one event per selected chat. */ + suspend fun toggleRead(ids: Collection) { + val chats = summaries(ids) + val toggle = readActions.toggle(chats) + chats.forEach { chat -> + val chatType = chat.metadata.type.analytics + analytics.track( + when (toggle) { + ChatReadToggle.MarkRead -> ChatEvents.markedRead(chatType = chatType, source = ChatReadSource.SELECTION) + ChatReadToggle.MarkUnread -> ChatEvents.markedUnread(chatType = chatType, source = ChatReadSource.SELECTION) + } + ) + } + } + + /** Read All over the main list. */ + suspend fun readAll(): ChatReadAllPlan { + val plan = readActions.readAll(mainList()) + analytics.track(ChatEvents.readAll(count = plan.count)) + return plan + } + + /** Select mode's Unmute, offered only when every selected chat is muted. */ + suspend fun unmute(ids: Collection) = coroutineScope { + summaries(ids).forEach { chat -> + launch { + val result = chatCoordinator.unmute(chat.metadata.chatId) + analytics.track( + ChatEvents.unmuted( + chatType = chat.metadata.type.analytics, + state = if (result.isSuccess) AnalyticsState.SUCCESS else AnalyticsState.FAILURE, + error = result.exceptionOrNull()?.chatResult, + ) + ) + } + } + } + + suspend fun archive(ids: Collection) = ids.forEach { archiveStore.archive(it) } + + suspend fun unarchive(ids: Collection) = ids.forEach { archiveStore.unarchive(it) } +} + +/** [ids]' chat types, by index, for the bulk mute sheet's per-chat events. */ +internal fun ChatRowActions.chatTypes(ids: List): Pair, List> { + val chats = summaries(ids) + return chats.map { it.metadata.chatId } to chats.map { it.metadata.type } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt new file mode 100644 index 0000000000..8db45dd07b --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatSelectionActions.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.chat.isMutedAt +import com.flipcash.shared.chat.ChatMuteToggle +import com.flipcash.shared.chat.ChatReadToggle +import com.flipcash.shared.chat.bulkMuteToggle +import com.flipcash.shared.chat.bulkReadToggle +import com.flipcash.shared.chat.ui.ConversationReference + +/** + * What select mode's bar offers for the selected rows: Mark as Read when any is unread (else Mark + * as Unread), and Unmute when every one is muted (else Mute). The rules are the shared ones. + */ +data class ChatSelectionActions( + val count: Int, + val read: ChatReadToggle, + val mute: ChatMuteToggle, +) { + companion object { + fun of(selected: List) = ChatSelectionActions( + count = selected.size, + read = bulkReadToggle(selected.map { it.isUnread }), + mute = bulkMuteToggle(selected.map { it.viewerState.isMutedAt() }), + ) + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 6dba8a2010..56d9bcec73 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -7,6 +7,7 @@ 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.ChatType import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore @@ -25,6 +26,8 @@ import com.flipcash.shared.chat.chatListFeeds import com.flipcash.shared.chat.hostedPrivateGroups import com.flipcash.shared.chat.currentChatListFeeds import com.flipcash.shared.chat.projectChatList +import com.flipcash.shared.chat.toggledSelection +import com.flipcash.analytics.ChatReadSource import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper @@ -53,6 +56,7 @@ open class ChatsViewModel @Inject constructor( private val lobbyRequests: LobbyRequests, private val hostLobbies: HostLobbies, private val paymentRequests: PaymentRequests, + private val rowActions: ChatRowActions, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -69,8 +73,24 @@ open class ChatsViewModel @Inject constructor( // 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, + // Select mode: the chats picked so far, or null outside it. Empty right after Select Chats. + val selection: Set? = null, ) { + val isSelecting: Boolean get() = selection != null + + /** The selected rows still in the main list, in list order. */ + val selectedChats: List + get() { + val ids = selection ?: return emptyList() + return chats.dataOrNull.orEmpty().filter { it.chatId in ids } + } + + val selectionActions: ChatSelectionActions get() = ChatSelectionActions.of(selectedChats) + + /** Read All has something to do: a main-list chat with unread messages, or marked. */ + val canReadAll: Boolean get() = chats.dataOrNull.orEmpty().any { it.isUnread } + /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -101,6 +121,12 @@ open class ChatsViewModel @Inject constructor( data class FilterSelected(val filter: ChatListFilter) : Event data object ChipsRevealed : Event + + data class SelectionStarted(val chatId: ChatId?) : Event + + data class SelectionToggled(val chatId: ChatId) : Event + + data object SelectionEnded : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -115,6 +141,62 @@ open class ChatsViewModel @Inject constructor( viewModelScope.launch { archiveStore.unarchive(chatId) } } + /** The leading swipe: Mark as Read or Mark as Unread, whichever the row offers. */ + fun toggleRead(chatId: ChatId) { + viewModelScope.launch { rowActions.toggleRead(chatId, ChatReadSource.SWIPE) } + } + + /** A long press (with that chat) or Select Chats (with none). */ + fun startSelection(chatId: ChatId? = null) = dispatchEvent(Event.SelectionStarted(chatId)) + + fun toggleSelection(chatId: ChatId) = dispatchEvent(Event.SelectionToggled(chatId)) + + fun endSelection() = dispatchEvent(Event.SelectionEnded) + + /** Select mode's read toggle. Leaves select mode. */ + fun readSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.toggleRead(ids) } } + + /** Select mode's Unmute, offered when every selected chat is muted. Leaves select mode. */ + fun unmuteSelection() = actOnSelection { ids -> viewModelScope.launch { rowActions.unmute(ids) } } + + /** + * Select mode's Mute: the selection and its chat types for the one mute sheet, or null with + * nothing selected. Leaves select mode; the sheet applies the duration. + */ + fun muteSelection(): Pair, List>? { + var target: Pair, List>? = null + actOnSelection { ids -> target = rowActions.chatTypes(ids) } + return target + } + + /** Select mode's Archive: archives the selection and returns it, for the toast's Undo. */ + fun archiveSelection(): List { + var archived = emptyList() + actOnSelection { ids -> + archived = ids + viewModelScope.launch { rowActions.archive(ids) } + } + return archived + } + + fun unarchive(chatIds: List) { + viewModelScope.launch { rowActions.unarchive(chatIds) } + } + + /** + * Read All, once confirmed. The screen asks first (it moves the READ pointer, which everyone in + * those chats sees), so Cancel leaves everything as it was. + */ + fun readAll() { + viewModelScope.launch { rowActions.readAll() } + } + + private inline fun actOnSelection(action: (List) -> Unit) { + val ids = stateFlow.value.selectedChats.map { it.chatId } + if (ids.isNotEmpty()) action(ids) + endSelection() + } + init { // No push says a join request was answered; an admitted group only shows up once the feed // is synced, which refresh() does when GetChat says a request has left its lobby. @@ -145,7 +227,9 @@ open class ChatsViewModel @Inject constructor( archived = isArchived, muted = row.viewerState.isMutedAt(), hidden = false, - unread = row.unreadCount, + // A marked chat with nothing counted is unread by an unknown count, which the + // projection already counts as unread: one chat in the badge and the Unread chip. + unread = if (row.markedUnread && row.unreadCount == 0) null else row.unreadCount, pendingRequests = row.joinRequests.size, newestRequestMs = row.joinRequests.maxOfOrNull { it.enteredAt.toEpochMilliseconds() } ?: 0L, ) @@ -208,6 +292,11 @@ open class ChatsViewModel @Inject constructor( // 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) } + is Event.SelectionStarted -> { state -> state.copy(selection = setOfNotNull(event.chatId)) } + is Event.SelectionToggled -> { state -> + state.copy(selection = state.selection?.toggledSelection(event.chatId)) + } + Event.SelectionEnded -> { state -> state.copy(selection = null) } } } } diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt new file mode 100644 index 0000000000..ceb4e7dfcb --- /dev/null +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.tipping.internal.components + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_markAsRead +import com.flipcash.core.resources.action_markAsUnread +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SwipeAction +import com.getcode.util.resources.Res as UiRes +import com.getcode.util.resources.ic_mark_chat_read +import com.getcode.util.resources.ic_mark_chat_unread +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * The leading swipe on a chat list row: Mark as Read on an unread chat, Mark as Unread otherwise. + * A circle in the unread dot's colour, sized like the trailing Archive and Mute circles. + */ +@Composable +internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pair { + val label = stringResource(if (isUnread) Res.string.action_markAsRead else Res.string.action_markAsUnread) + val painter = painterResource( + if (isUnread) UiRes.drawable.ic_mark_chat_read else UiRes.drawable.ic_mark_chat_unread + ) + return SwipeAction( + background = CodeTheme.colors.indicator, + onTriggered = onTriggered, + // The row stays in the list, so a full swipe has to send it home rather than leave it + // settled off screen. + resetOnDismiss = true, + ) { + Icon( + painter = painter, + contentDescription = label, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } to label +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 123b0ff80d..0afffdbdcb 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -4,7 +4,14 @@ import com.flipcash.core.resources.Res import com.flipcash.core.resources.subtitle_peopleWantToJoin import com.flipcash.core.resources.subtitle_wantsToJoin import com.flipcash.core.resources.title_unnamedUser +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Icon +import androidx.compose.ui.draw.drawWithContent +import com.getcode.util.resources.ic_check_circle +import org.jetbrains.compose.resources.painterResource +import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize @@ -40,19 +47,34 @@ internal fun TipChatRow( chat: ConversationReference, modifier: Modifier = Modifier, showDivider: Boolean = true, + // Select mode: whether this row is picked, or null outside select mode. In it a circle leads + // the row, the content shifts right to make room, and the chevron goes (a tap toggles). + selected: Boolean? = null, + onLongClick: (() -> Unit)? = null, onClick: () -> Unit, ) { + val selectedTint = CodeTheme.colors.surfaceVariant ChatListRow( - modifier = modifier, + modifier = modifier.drawWithContent { + drawContent() + // Over the row rather than behind it: the shared row paints its own opaque background. + if (selected == true) drawRect(selectedTint) + }, avatar = { - ContactAvatar( - image = chat.image, - displayName = chat.name.orEmpty(), - access = chat.avatarAccess, - modifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x9) - .clip(CircleShape), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + ) { + if (selected != null) SelectCircle(selected) + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x9) + .clip(CircleShape), + ) + } }, title = { Row( @@ -81,7 +103,7 @@ internal fun TipChatRow( ChatRowTrailing( lastActivity = chat.rowActivity, unreadCount = chat.rowUnreadCount, - canOpen = true, + canOpen = selected == null, ) }, subtitle = { @@ -109,10 +131,31 @@ internal fun TipChatRow( ) }, showDivider = showDivider, + onLongClick = onLongClick, onClick = onClick, ) } +/** Select mode's leading circle: an outline when not picked, the filled check when picked. */ +@Composable +private fun SelectCircle(selected: Boolean) { + val size = CodeTheme.dimens.staticGrid.x4 + if (selected) { + Icon( + painter = painterResource(com.getcode.util.resources.Res.drawable.ic_check_circle), + contentDescription = null, + tint = CodeTheme.colors.indicator, + modifier = Modifier.requiredSize(size), + ) + } else { + Box( + Modifier + .requiredSize(size) + .border(1.5.dp, CodeTheme.colors.textSecondary, CircleShape) + ) + } +} + /** * "Jasper wants to join" for one requester, "3 people want to join" for several, or null with * nobody waiting. Named the way the in-chat Join Requests anchor names them. diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index 53c4249fad..227780be44 100644 --- a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -87,6 +87,7 @@ class ChatsViewModelArchiveTest { lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = mockk(relaxed = true), ) val states = mutableListOf() diff --git a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt index e390a0b047..9fb7dfa22e 100644 --- a/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt +++ b/apps/flipcash/features/chat-list/src/commonTest/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -111,6 +111,7 @@ class ChatsViewModelRatesTest { lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + rowActions = mockk(relaxed = true), ) val states = mutableListOf() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt index e26c2bd98d..b193f9225b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/MuteChatScreen.kt @@ -44,3 +44,25 @@ fun MuteChatScreen(chatId: ChatId, chatType: ChatType) { onDismiss = dismissSheet, ) } + +/** + * The mute sheet for several chats, behind `AppRoute.Messaging.MuteChats`: the chat list's select + * mode opens it once for the whole selection. It always offers the durations (the selection holds + * at least one audible chat, or the bar would have offered Unmute), and the one picked applies to + * every chat in [chatIds], already muted ones included. + */ +@Composable +fun MuteChatsScreen(chatIds: List, chatTypes: List) { + val viewModel = hiltViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + MuteChatSheet( + isMuted = false, + onMute = { option -> + viewModel.muteAll(chatIds, chatTypes, option) + dismissSheet() + }, + onUnmute = dismissSheet, + onDismiss = dismissSheet, + ) +} 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 16debf9042..44c41f17a9 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 @@ -69,6 +69,14 @@ internal class MuteChatViewModel @Inject constructor( .also { analytics.trackMuted(chatType, option, it) } } + /** + * The chat list's bulk Mute: [option] applied to every chat in [chatIds], already muted ones + * included. [chatTypes] pairs with [chatIds] by index, for each chat's own mute event. + */ + fun muteAll(chatIds: List, chatTypes: List, option: MuteOption) { + chatIds.zip(chatTypes).forEach { (chatId, chatType) -> mute(chatId, chatType, option) } + } + fun unmute(chatId: ChatId, chatType: ChatType) = request( errorTitle = R.string.error_title_failedToUnmute, errorDescription = R.string.error_description_failedToUnmute, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt index 4a79d5ba02..e276d5de33 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatAnalyticsTest.kt @@ -69,6 +69,27 @@ class MuteChatAnalyticsTest { ) } + // Spec case 14: select mode's Mute opens the sheet once for the whole selection, and the one + // duration picked applies to every chat in it, the one already muted included. + @Test + fun `a bulk mute applies one duration to every selected chat`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { chatCoordinator.mute(any(), any()) } returns Result.success(Unit) + val alreadyMuted = ChatId(ByteArray(16) { 4 }.toList()) + + viewModel().muteAll(listOf(chatId, alreadyMuted), listOf(ChatType.TIP_DM, ChatType.GROUP), MuteOption.EightHours) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.mute(chatId, match { it is MuteState.Until }) } + coVerify(exactly = 1) { chatCoordinator.mute(alreadyMuted, match { it is MuteState.Until }) } + assertEquals( + listOf( + ChatEvents.muted(AnalyticsChatType.TIP, MuteDuration.EIGHT_HOURS, AnalyticsState.SUCCESS, error = null), + ChatEvents.muted(AnalyticsChatType.GROUP, MuteDuration.EIGHT_HOURS, AnalyticsState.SUCCESS, error = null), + ), + analytics.events, + ) + } + @Test fun `a refused mute reports the result name`() = runTest(mainCoroutineRule.dispatcher) { coEvery { chatCoordinator.mute(any(), any()) } returns Result.failure(MuteChatError.NotFound()) diff --git a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt index 257d29b6e1..bf94b2150a 100644 --- a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -12,22 +12,9 @@ import com.getcode.solana.keys.base58 import com.flipcash.analytics.ChatType as AnalyticsChatType import com.flipcash.analytics.DisplayNameSource as AnalyticsDisplayNameSource import com.flipcash.app.core.DisplayNameSource -import com.flipcash.services.models.AdmitLobbyMemberError -import com.flipcash.services.models.BanUserError -import com.flipcash.services.models.CreateInviteError -import com.flipcash.services.models.DenyLobbyMemberError -import com.flipcash.services.models.EditChatError -import com.flipcash.services.models.JoinChatError -import com.flipcash.services.models.LeaveChatError -import com.flipcash.services.models.MuteChatError -import com.flipcash.services.models.RevokeInviteError -import com.flipcash.services.models.SilenceUserError -import com.flipcash.services.models.StartChatError -import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType -import com.getcode.opencode.model.core.errors.ValidationException /* * Converts Android domain types into what the shared builders in `:libs:analytics-events` @@ -38,42 +25,6 @@ import com.getcode.opencode.model.core.errors.ValidationException val Throwable.analytics: String get() = message.orEmpty() -/** - * The `Error` property on the group and mute events: the chat RPC's result name as the proto - * spells it, or `Network` when the call failed in transport. Spelled out rather than taken from - * `javaClass.simpleName`, which R8 renames in release builds. - * - * `Validation` is the request failing proto validation before it was sent, which has no result - * name because the server never answered. - */ -val Throwable.chatResult: String - get() = when (this) { - is StartChatError.Denied, is EditChatError.Denied, is JoinChatError.Denied, - is LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied, - is CreateInviteError.Denied, is RevokeInviteError.Denied, is AdmitLobbyMemberError.Denied, - is DenyLobbyMemberError.Denied, is SilenceUserError.Denied, is BanUserError.Denied -> "Denied" - is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, - is MuteChatError.NotFound, is UnmuteChatError.NotFound, - is CreateInviteError.NotFound, is RevokeInviteError.NotFound, is AdmitLobbyMemberError.NotFound, - is DenyLobbyMemberError.NotFound, is SilenceUserError.NotFound, is BanUserError.NotFound -> "NotFound" - is CreateInviteError.TooManyInvites -> "TooManyInvites" - is CreateInviteError.InvalidExpiresAt -> "InvalidExpiresAt" - is CreateInviteError.InvalidSignature -> "InvalidSignature" - is AdmitLobbyMemberError.NotInLobby -> "NotInLobby" - is SilenceUserError.CannotRestrictCreator, is BanUserError.CannotRestrictCreator -> "CannotRestrictCreator" - is StartChatError.TitleModerated, is EditChatError.TitleModerated -> "TitleModerated" - is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" - is StartChatError.InvalidRules -> "InvalidRules" - is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" - is JoinChatError.Banned -> "Banned" - is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, - is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized, - is CreateInviteError.Unrecognized, is RevokeInviteError.Unrecognized, is AdmitLobbyMemberError.Unrecognized, - is DenyLobbyMemberError.Unrecognized, is SilenceUserError.Unrecognized, is BanUserError.Unrecognized -> "Unrecognized" - is ValidationException -> "Validation" - else -> "Network" - } - /** * The `Gate Mint` property: the mint a group's listener balance rule names, or null when the * group has no token gate — no rules, or a rule any currency satisfies. diff --git a/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt b/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt new file mode 100644 index 0000000000..4a91be5bad --- /dev/null +++ b/apps/flipcash/shared/analytics/src/commonMain/kotlin/com/flipcash/app/analytics/ChatResult.kt @@ -0,0 +1,51 @@ +package com.flipcash.app.analytics + +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.EditChatError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.SilenceUserError +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.UnmuteChatError +import com.getcode.opencode.model.core.errors.ValidationException + +/** + * The `Error` property on the group and mute events: the chat RPC's result name as the proto + * spells it, or `Network` when the call failed in transport. Spelled out rather than taken from + * `javaClass.simpleName`, which R8 renames in release builds. + * + * `Validation` is the request failing proto validation before it was sent, which has no result + * name because the server never answered. + */ +val Throwable.chatResult: String + get() = when (this) { + is StartChatError.Denied, is EditChatError.Denied, is JoinChatError.Denied, + is LeaveChatError.Denied, is MuteChatError.Denied, is UnmuteChatError.Denied, + is CreateInviteError.Denied, is RevokeInviteError.Denied, is AdmitLobbyMemberError.Denied, + is DenyLobbyMemberError.Denied, is SilenceUserError.Denied, is BanUserError.Denied -> "Denied" + is EditChatError.NotFound, is JoinChatError.NotFound, is LeaveChatError.NotFound, + is MuteChatError.NotFound, is UnmuteChatError.NotFound, + is CreateInviteError.NotFound, is RevokeInviteError.NotFound, is AdmitLobbyMemberError.NotFound, + is DenyLobbyMemberError.NotFound, is SilenceUserError.NotFound, is BanUserError.NotFound -> "NotFound" + is CreateInviteError.TooManyInvites -> "TooManyInvites" + is CreateInviteError.InvalidExpiresAt -> "InvalidExpiresAt" + is CreateInviteError.InvalidSignature -> "InvalidSignature" + is AdmitLobbyMemberError.NotInLobby -> "NotInLobby" + is SilenceUserError.CannotRestrictCreator, is BanUserError.CannotRestrictCreator -> "CannotRestrictCreator" + is StartChatError.TitleModerated, is EditChatError.TitleModerated -> "TitleModerated" + is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" + is StartChatError.InvalidRules -> "InvalidRules" + is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" + is JoinChatError.Banned -> "Banned" + is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, + is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized, + is CreateInviteError.Unrecognized, is RevokeInviteError.Unrecognized, is AdmitLobbyMemberError.Unrecognized, + is DenyLobbyMemberError.Unrecognized, is SilenceUserError.Unrecognized, is BanUserError.Unrecognized -> "Unrecognized" + is ValidationException -> "Validation" + else -> "Network" + } diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt index a19c626bf8..c9067b8d66 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -4,6 +4,7 @@ import com.flipcash.core.resources.Res import com.flipcash.core.resources.label_chatReceipt_yesterday import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,6 +41,8 @@ fun ChatListRow( modifier: Modifier = Modifier, showDivider: Boolean = true, endAction: @Composable () -> Unit = {}, + /** A long press, for the chat list's select mode. Null leaves the row a plain click target. */ + onLongClick: (() -> Unit)? = null, onClick: () -> Unit, ) { Column( @@ -51,7 +54,13 @@ fun ChatListRow( modifier = Modifier .fillMaxWidth() .testTag("send_contact_row") - .clickable(onClick = onClick) + .then( + if (onLongClick != null) { + Modifier.combinedClickable(onLongClick = onLongClick, onClick = onClick) + } else { + Modifier.clickable(onClick = onClick) + } + ) .padding( horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3, diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index e70c871db9..1be1c41a3d 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -94,6 +94,7 @@ fun ChatSummary.toConversationReference( lastActivity = metadata.lastActivity, unreadCount = unreadCount, viewerState = metadata.viewerState, + markedUnread = markedUnread, ) } diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 2f2af7347e..4b2ef76c9e 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -66,7 +66,12 @@ data class ConversationReference( * otherwise. Requests read as unread on the row: they add to its count and move its time. */ val joinRequests: List = emptyList(), + /** Marked unread on this device ([com.flipcash.shared.chat.ChatMarkedUnreadStore]). */ + val markedUnread: Boolean = false, ) { + /** Unread messages, or marked unread: what the read actions and Read All go by. */ + val isUnread: Boolean get() = unreadCount != 0 || markedUnread + /** The time the row shows and sorts by: the later of [lastActivity] and the newest request. */ val rowActivity: Instant? get() { @@ -76,9 +81,15 @@ data class ConversationReference( /** * The row's unread pill: unread messages plus pending requests. A null [unreadCount] stays - * null (unread by an unknown count), since adding to an unknown number gives an unknown one. + * null (unread by an unknown count), since adding to an unknown number gives an unknown one, + * and so does a [markedUnread] chat with nothing to count. */ - val rowUnreadCount: Int? get() = unreadCount?.plus(joinRequests.size) + val rowUnreadCount: Int? + get() { + val count = unreadCount?.plus(joinRequests.size) ?: return null + // A marked chat with nothing counted draws the no-count dot; a known count wins. + return if (count == 0 && markedUnread) null else count + } /** * What to call this row: the chat's [title] for a group, otherwise the counterparty's diff --git a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt index 2b321183dd..a96d0befc1 100644 --- a/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt +++ b/apps/flipcash/shared/chat/src/commonMain/kotlin/com/flipcash/shared/chat/ChatReadRules.kt @@ -63,3 +63,13 @@ fun readAllPlan(main: List): ChatReadAllPlan { * Null when nothing was archived, so no toast is shown. */ fun archivedToastCount(archived: Int): Int? = archived.takeIf { it > 0 } + +/** + * Select mode's tap on [chatId]: adds it, or takes it out. Null when that takes out the last + * selected chat, which leaves select mode. (Select Chats enters with nothing selected; only a + * deselect empties it.) + */ +fun Set.toggledSelection(chatId: ChatId): Set? { + if (chatId !in this) return this + chatId + return (this - chatId).ifEmpty { null } +} From ecd5e85499746a68751957875c6678ba9f355412 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 14:00:42 -0500 Subject: [PATCH 06/16] feat(theme): indicatorSecondary, the unread blue's tonal ground The Mark as Read/Unread swipe draws its glyph in the unread blue on a dark blue, as Mute draws amber on dark amber. The token is the indicator at a tenth over the main background, baked flat: #172431 for Flipcash, matching how iOS derives warningSecondary from warning. Schemes that don't set it get the same derivation from their own indicator and background. --- .../theme/internal/FlipcashDesignSystem.kt | 3 +++ .../theme/internal/Flipcash2ColorSpecTest.kt | 26 +++++++++++++++++++ .../kotlin/com/getcode/theme/Theme.kt | 11 ++++++++ 3 files changed, 40 insertions(+) create mode 100644 apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt diff --git a/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index f39724f81f..e35900837c 100644 --- a/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/commonMain/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -35,6 +35,8 @@ object Flipcash2ColorSpec { val placeholderText = Color(0xFF4E4E4E) val cashBill = Color(0xFF06450F) val notification = Color(0xFF058AFF) + /** [notification] at 10% over [primary], baked flat: the ground of the Mark as Read/Unread swipe. */ + val notificationSecondary = Color(0xFF172431) val trackColor = Color.White.copy(alpha = 0.07f) val bannerThemed = Color(0xFF252526) val success = Color(0xFF1AC86A) @@ -96,6 +98,7 @@ private val colors = with(Flipcash2ColorSpec) { secondary = secondary, tertiary = BrandAccent, indicator = notification, + indicatorSecondary = notificationSecondary, badgeFocus = BadgeFocus, action = Gray50, onAction = White, diff --git a/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt b/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt new file mode 100644 index 0000000000..9612a0b57d --- /dev/null +++ b/apps/flipcash/shared/theme/src/commonTest/kotlin/com/flipcash/app/theme/internal/Flipcash2ColorSpecTest.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.theme.internal + +import androidx.compose.ui.graphics.Color +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals + +class Flipcash2ColorSpecTest { + + // The flat token is a derivation, so a change to the blue or the background has to move it too. + // Worked per channel at an exact 10% (as iOS derives its secondaries); Compose's `compositeOver` + // quantises the alpha to 26/255 and lands one step greener. + @Test + fun `notificationSecondary is the notification blue at a tenth over the main background`() { + with(Flipcash2ColorSpec) { + assertEquals(tenthOver(notification, primary), rgb(notificationSecondary)) + } + } + + private fun tenthOver(top: Color, ground: Color): List = + listOf(top.red to ground.red, top.green to ground.green, top.blue to ground.blue) + .map { (t, g) -> ((0.1f * t + 0.9f * g) * 255).roundToInt() } + + private fun rgb(color: Color): List = + listOf(color.red, color.green, color.blue).map { (it * 255).roundToInt() } +} diff --git a/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt index bc35d1646c..a312d1806e 100644 --- a/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/commonMain/kotlin/com/getcode/theme/Theme.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.compositeOver import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color @@ -166,6 +167,12 @@ class ColorScheme( accessKey: GradientSpec, contactAvatar: GradientSpec, chat: ChatColors, + /** + * The ground [indicator] sits on when it labels an action rather than counting something, e.g. + * the chat row's Mark as Read/Unread swipe: the indicator at a tenth of its strength over + * [background], baked flat. Only true against the main background; it is not a general wash. + */ + indicatorSecondary: Color = indicator.copy(alpha = 0.1f).compositeOver(background), ) { var brand by mutableStateOf(brand) private set @@ -221,6 +228,8 @@ class ColorScheme( private set var badgeFocus by mutableStateOf(badgeFocus) private set + var indicatorSecondary by mutableStateOf(indicatorSecondary) + private set var action by mutableStateOf(action) private set var onAction by mutableStateOf(onAction) @@ -291,6 +300,7 @@ class ColorScheme( tertiary = other.tertiary indicator = other.indicator badgeFocus = other.badgeFocus + indicatorSecondary = other.indicatorSecondary action = other.action onAction = other.onAction border = other.border @@ -359,6 +369,7 @@ class ColorScheme( accessKey = accessKey, contactAvatar = contactAvatar, chat = chat, + indicatorSecondary = indicatorSecondary, ) } From 59125583b5ceb46c961746b5581a2b24d0414db8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 14:00:42 -0500 Subject: [PATCH 07/16] feat(chats): tonal leading swipe, select circle that toggles in place The leading swipe now matches the trailing Mute circle's tonal style: the glyph in the unread blue on indicatorSecondary instead of a solid blue disc. Toggling a row only crossfades its circle between the outline and a white check on the unread blue; both fill the same 20dp (the knocked-out ic_check_circle disc drew 16.7dp of it), so nothing moves. The circle slides in and out only as select mode starts and ends. The screen drives that slide rather than each row: the row is rebuilt when select mode drops its swipe wrapper, which skipped a slide kept inside it (the enter jumped in one frame on device). --- .../app/tipping/ArchivedChatsContent.kt | 4 + .../app/tipping/ChatsScreenContent.kt | 7 ++ .../internal/components/ChatSelection.kt | 27 +++++- .../tipping/internal/components/TipChatRow.kt | 86 ++++++++++++++----- 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt index daabed7463..a53a668fba 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -7,6 +7,7 @@ import com.flipcash.core.resources.Res import com.flipcash.core.resources.content_description_unarchiveChat import com.flipcash.core.resources.title_noArchivedChats import androidx.compose.foundation.layout.Box +import com.flipcash.app.tipping.internal.components.rememberSelectCircles import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -61,6 +62,7 @@ internal fun ArchivedChatsContent( ) { padding -> // One list for the rows and the empty state alike, so unarchiving the last chat fades its // row out and the message in, rather than swapping the whole list for the message at once. + val circles = rememberSelectCircles(selection) LazyColumn( modifier = Modifier.fillMaxSize().testTag("archived_list"), contentPadding = PaddingValues(top = padding.calculateTopPadding()), @@ -102,6 +104,8 @@ internal fun ArchivedChatsContent( chat = chat, showDivider = index < chats.lastIndex, selected = selection?.let { chat.chatId in it }, + circleReveal = circles.reveal, + circleChecked = chat.chatId in circles.checked, onLongClick = { if (selection == null) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index 0d5f2a5931..87e1a75d42 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -1,6 +1,8 @@ package com.flipcash.app.tipping import com.flipcash.app.tipping.internal.components.markReadSwipeAction +import com.flipcash.app.tipping.internal.components.SelectCircles +import com.flipcash.app.tipping.internal.components.rememberSelectCircles import com.flipcash.services.models.chat.ChatId import com.flipcash.core.resources.Res import com.flipcash.core.resources.action_undo @@ -179,6 +181,7 @@ fun ChatsScreenContent( barPlacement = ScaffoldBarPlacement.Overlay, topBar = topBar, ) { barPadding -> + val circles = rememberSelectCircles(state.selection) Box(modifier = Modifier.fillMaxSize()) { LazyColumn( modifier = Modifier @@ -294,6 +297,7 @@ fun ChatsScreenContent( onArchive = onArchive, revealGroup = revealGroup, parkFiller = parkFiller, + circles = circles, ) } @@ -363,6 +367,7 @@ private fun LazyListScope.tipChatItems( onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, parkFiller: ParkFiller, + circles: SelectCircles, ) { // 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 @@ -384,6 +389,8 @@ private fun LazyListScope.tipChatItems( chat = chat, showDivider = index < chats.lastIndex, selected = selection?.let { chat.chatId in it }, + circleReveal = circles.reveal, + circleChecked = chat.chatId in circles.checked, onLongClick = { onLongClick(chat) }, ) { onClick(chat) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt index ceb4e7dfcb..81c4a305a6 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tipping.internal.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.runtime.remember +import com.flipcash.services.models.chat.ChatId import androidx.compose.foundation.layout.requiredSize import androidx.compose.material.icons.outlined.Archive import androidx.compose.material3.Icon @@ -16,9 +19,27 @@ import com.getcode.util.resources.ic_mark_chat_unread import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +/** + * Select mode's circles, shared by every row on a list: how far they have slid in, and which are + * checked. Held by the screen rather than each row, because a row is rebuilt when select mode drops + * its swipe wrapper, which would restart a slide kept inside it. + */ +internal class SelectCircles(val reveal: () -> Float, val checked: Set) + +@Composable +internal fun rememberSelectCircles(selection: Set?): SelectCircles { + val reveal = animateFloatAsState(if (selection != null) 1f else 0f, label = "select circles") + // Leaving select mode drops the selection while the circles are still sliding out, so they keep + // drawing the one they last had. Not state: it only changes when `selection` already has. + val last = remember { arrayOf(emptySet()) } + if (selection != null) last[0] = selection + return SelectCircles(reveal = { reveal.value }, checked = last[0]) +} + /** * The leading swipe on a chat list row: Mark as Read on an unread chat, Mark as Unread otherwise. - * A circle in the unread dot's colour, sized like the trailing Archive and Mute circles. + * Tonal like the trailing Mute circle: the glyph in the unread blue on its dark-blue ground, sized + * like the trailing Archive and Mute circles. */ @Composable internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pair { @@ -27,7 +48,7 @@ internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pa if (isUnread) UiRes.drawable.ic_mark_chat_read else UiRes.drawable.ic_mark_chat_unread ) return SwipeAction( - background = CodeTheme.colors.indicator, + background = CodeTheme.colors.indicatorSecondary, onTriggered = onTriggered, // The row stays in the list, so a full swipe has to send it home rather than leave it // settled off screen. @@ -36,7 +57,7 @@ internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pa Icon( painter = painter, contentDescription = label, - tint = CodeTheme.colors.textMain, + tint = CodeTheme.colors.indicator, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) } to label diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 0afffdbdcb..61c3d354f0 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -4,13 +4,21 @@ import com.flipcash.core.resources.Res import com.flipcash.core.resources.subtitle_peopleWantToJoin import com.flipcash.core.resources.subtitle_wantsToJoin import com.flipcash.core.resources.title_unnamedUser +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.ui.graphics.Color import androidx.compose.foundation.border +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.material3.Icon import androidx.compose.ui.draw.drawWithContent -import com.getcode.util.resources.ic_check_circle -import org.jetbrains.compose.resources.painterResource import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,6 +58,11 @@ internal fun TipChatRow( // Select mode: whether this row is picked, or null outside select mode. In it a circle leads // the row, the content shifts right to make room, and the chevron goes (a tap toggles). selected: Boolean? = null, + // How far select mode's circle has slid in (0 to 1) and whether it is checked. The screen + // drives both so the slide survives the row being rebuilt when select mode starts or ends, and + // a circle sliding out keeps the state it had. + circleReveal: () -> Float = { if (selected != null) 1f else 0f }, + circleChecked: Boolean = selected == true, onLongClick: (() -> Unit)? = null, onClick: () -> Unit, ) { @@ -61,11 +74,8 @@ internal fun TipChatRow( if (selected == true) drawRect(selectedTint) }, avatar = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), - ) { - if (selected != null) SelectCircle(selected) + Row(verticalAlignment = Alignment.CenterVertically) { + SelectCircleSlot(circleReveal, circleChecked) ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), @@ -136,23 +146,57 @@ internal fun TipChatRow( ) } -/** Select mode's leading circle: an outline when not picked, the filled check when picked. */ +/** + * Select mode's leading circle. It slides in, pushing the row content right, only as select mode + * starts ([reveal] going 0 to 1), and back out as it ends; toggling a row changes the circle in + * place. Reads [reveal] in layout and draw only, so the slide doesn't recompose the row. + */ +@Composable +private fun SelectCircleSlot(reveal: () -> Float, checked: Boolean) { + Box( + Modifier + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = (placeable.width * reveal()).roundToInt() + layout(width, placeable.height) { placeable.placeRelative(width - placeable.width, 0) } + } + .graphicsLayer { alpha = reveal() } + ) { + // The gap to the avatar slides with the circle rather than popping in ahead of it. + Box(Modifier.padding(end = CodeTheme.dimens.staticGrid.x3)) { SelectCircle(checked) } + } +} + +/** + * An outline in the secondary text colour when not picked, a white check on the unread blue when + * picked. Both fill the same circle, so a toggle only crossfades between them. + */ @Composable private fun SelectCircle(selected: Boolean) { val size = CodeTheme.dimens.staticGrid.x4 - if (selected) { - Icon( - painter = painterResource(com.getcode.util.resources.Res.drawable.ic_check_circle), - contentDescription = null, - tint = CodeTheme.colors.indicator, - modifier = Modifier.requiredSize(size), - ) - } else { - Box( - Modifier - .requiredSize(size) - .border(1.5.dp, CodeTheme.colors.textSecondary, CircleShape) - ) + Crossfade(targetState = selected, label = "select circle") { picked -> + if (picked) { + Box( + modifier = Modifier + .requiredSize(size) + .background(CodeTheme.colors.indicator, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x3), + ) + } + } else { + Box( + Modifier + .requiredSize(size) + .border(1.5.dp, CodeTheme.colors.textSecondary, CircleShape) + ) + } } } From 0b636892b8fb4ad1dd0daff2ecfbb83736265d80 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 15:10:22 -0500 Subject: [PATCH 08/16] test(chat): marked-unread marks survive logout per account Test case 8 now signs in as account X, marks two chats, logs out, signs in as Y (no marks), then back in as X (both marks back). The database file is per account, so logout keeps the marks; clearCache still erases them. --- .../chat/RealChatMarkedUnreadStoreTest.kt | 29 +++++++++++++++++-- .../chat/ChatCoordinatorTeardownTest.kt | 3 +- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt index 6205139d92..55874ee933 100644 --- a/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt +++ b/apps/flipcash/shared/chat/src/androidHostTest/kotlin/com/flipcash/shared/chat/RealChatMarkedUnreadStoreTest.kt @@ -24,9 +24,12 @@ class RealChatMarkedUnreadStoreTest { private val c = ChatId("55667788") private val store: ChatMarkedUnreadStore = RealChatMarkedUnreadStore(ChatMarkedUnreadDataSource()) { 1_000L } + private fun signIn(entropy: String) = + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), entropy) + @Before fun setUp() { - FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "chat-marked-unread-store-test") + signIn(ACCOUNT_X) runBlocking { store.clearAll() } } @@ -56,7 +59,23 @@ class RealChatMarkedUnreadStoreTest { assertEquals(setOf(b), store.observeMarked().first()) } - // Test case 8: logout empties the marked set. + // Test case 8: marks survive logout, per account. The database file is per account, so + // logging out closes X's marks away from Y and signing back in as X brings them back. + @Test + fun `marks survive logout per account`() = runBlocking { + store.mark(a) + store.mark(b) + + FlipcashDatabase.closeDb() + signIn(ACCOUNT_Y) + assertEquals(emptySet(), store.observeMarked().first()) + + FlipcashDatabase.closeDb() + signIn(ACCOUNT_X) + assertEquals(setOf(a, b), store.observeMarked().first()) + } + + // Erasing the cache (clearCache) wipes the marks. @Test fun `clearAll empties the marked set`() = runBlocking { store.mark(a) @@ -70,4 +89,10 @@ class RealChatMarkedUnreadStoreTest { FlipcashDatabase.closeDb() assertEquals(emptySet(), store.observeMarked().first()) } + + private companion object { + // The database file is named from the first 6 bytes of the entropy, so these differ there. + const val ACCOUNT_X = "xxxxxx-chat-marked-unread-store-test" + const val ACCOUNT_Y = "yyyyyy-chat-marked-unread-store-test" + } } diff --git a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index c8e13595db..7cf398551f 100644 --- a/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/commonTest/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -223,7 +223,8 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { archiveStore.unarchive(any()) } } - // Test case 8. Like the archive set: the database is per-account, so erasure wipes the marks. + // Like the archive set: logout keeps the marks (the database is per account; test case 8 is in + // RealChatMarkedUnreadStoreTest), erasure wipes them. @Test fun `clearCache drops the unread marks, teardown keeps them`() = runTest { coordinator.teardown() From f90570d927f62605d1624fa1c8b3a977d94bc422 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 19:38:07 -0500 Subject: [PATCH 09/16] feat(ui): label swipe action circles SwipeAction takes an optional label, drawn under the circle in caption text in the secondary colour. A labelled action's slot widens to fit its title, so the reveal width follows the longest label. Unlabelled actions keep their full-height circle. Chat list and archived list rows label Mark as Read/Unread, Mute/Unmute and Archive/Unarchive with the select-mode bar's strings. Members rows use the member sheet's titles. Profile and region rows stay unlabelled. --- .../app/tipping/ArchivedChatsContent.kt | 3 + .../app/tipping/ChatsScreenContent.kt | 8 + .../internal/components/ChatSelection.kt | 3 +- .../internal/screens/profile/MembersScreen.kt | 7 + .../ui/components/SwipeActionRowTest.kt | 76 +++++++- .../getcode/ui/components/SwipeActionRow.kt | 169 +++++++++++++----- 6 files changed, 218 insertions(+), 48 deletions(-) diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt index a53a668fba..1441ec9b0c 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ArchivedChatsContent.kt @@ -1,5 +1,6 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.action_unarchive import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import com.flipcash.app.tipping.internal.components.markReadSwipeAction @@ -133,6 +134,7 @@ private fun UnarchiveSwipeRow( content: @Composable () -> Unit, ) { val label = stringResource(Res.string.content_description_unarchiveChat) + val unarchiveTitle = stringResource(Res.string.action_unarchive) val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) if (!swipeEnabled) { Box(modifier) { content() } @@ -144,6 +146,7 @@ private fun UnarchiveSwipeRow( SwipeAction( background = CodeTheme.colors.surfaceVariant, onTriggered = onUnarchive, + label = unarchiveTitle, ) { Icon( imageVector = Icons.Outlined.Unarchive, diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index 87e1a75d42..3fdf6f0e24 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tipping +import com.flipcash.core.resources.action_unmute +import com.flipcash.core.resources.action_mute +import com.flipcash.core.resources.action_archive import com.flipcash.app.tipping.internal.components.markReadSwipeAction import com.flipcash.app.tipping.internal.components.SelectCircles import com.flipcash.app.tipping.internal.components.rememberSelectCircles @@ -431,6 +434,9 @@ private fun ChatSwipeRow( else Res.string.content_description_muteChat ) val archiveLabel = stringResource(Res.string.content_description_archiveChat) + // The titles under the circles use the select-mode bar's wording. + val muteTitle = stringResource(if (isMuted) Res.string.action_unmute else Res.string.action_mute) + val archiveTitle = stringResource(Res.string.action_archive) val (readAction, readLabel) = markReadSwipeAction(isUnread, onToggleRead) if (!swipeEnabled) { Box(modifier) { content() } @@ -442,6 +448,7 @@ private fun ChatSwipeRow( SwipeAction( background = CodeTheme.colors.surfaceVariant, onTriggered = onArchive, + label = archiveTitle, ) { Icon( imageVector = Icons.Outlined.Archive, @@ -456,6 +463,7 @@ private fun ChatSwipeRow( background = CodeTheme.colors.warning.copy(alpha = 0.15f), onTriggered = onMute, resetOnDismiss = true, + label = muteTitle, ) { Icon( imageVector = if (isMuted) { diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt index 81c4a305a6..5a1a1d149c 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt @@ -39,7 +39,7 @@ internal fun rememberSelectCircles(selection: Set?): SelectCircles { /** * The leading swipe on a chat list row: Mark as Read on an unread chat, Mark as Unread otherwise. * Tonal like the trailing Mute circle: the glyph in the unread blue on its dark-blue ground, sized - * like the trailing Archive and Mute circles. + * like the trailing Archive and Mute circles, with the select-mode bar's wording under it. */ @Composable internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pair { @@ -53,6 +53,7 @@ internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pa // The row stays in the list, so a full swipe has to send it home rather than leave it // settled off screen. resetOnDismiss = true, + label = label, ) { Icon( painter = painter, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt index 6e886f1447..ccf49fc743 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -273,6 +273,10 @@ internal fun MemberSwipeRow( val restrictLabel = stringResource(R.string.content_description_restrictMember) val liftLabel = stringResource(R.string.content_description_liftRestriction) val removeLabel = stringResource(R.string.content_description_removeMember) + // The member sheet's titles for the same actions, drawn under the circles. + val restrictTitle = stringResource(R.string.action_silenceMemberShort) + val liftTitle = stringResource(R.string.action_liftRestriction) + val removeTitle = stringResource(R.string.action_removeFromGroup) val swipeActions = buildList { if (actions.restrict) { add( @@ -280,6 +284,7 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.warning.copy(alpha = 0.15f), onTriggered = onRestrict, resetOnDismiss = true, + label = restrictTitle, ) { SwipeIcon(SpeakerSlash, restrictLabel, CodeTheme.colors.warning) } @@ -291,6 +296,7 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.surfaceVariant, onTriggered = onLift, resetOnDismiss = true, + label = liftTitle, ) { SwipeIcon(Icons.Outlined.LockOpen, liftLabel, CodeTheme.colors.textMain) } @@ -302,6 +308,7 @@ internal fun MemberSwipeRow( background = CodeTheme.colors.error.copy(alpha = 0.15f), onTriggered = onRemove, resetOnDismiss = true, + label = removeTitle, ) { SwipeIcon(Icons.Outlined.PersonRemove, removeLabel, CodeTheme.colors.error) } diff --git a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt index 773220c37f..a3e00e5342 100644 --- a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt +++ b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt @@ -1,23 +1,34 @@ package com.getcode.ui.components import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.click import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode import kotlin.test.assertEquals /** @@ -26,6 +37,7 @@ import kotlin.test.assertEquals * move on a swipe right. */ @RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) class SwipeActionRowTest { @get:Rule @@ -33,10 +45,21 @@ class SwipeActionRowTest { private var leadingFired = 0 private var trailingFired = 0 + private var labelWidth = 0.dp + private var circleRevealWidth = 0.dp - private fun show(withLeading: Boolean) { + private fun show(withLeading: Boolean, leadingLabel: String? = null) { composeTestRule.setContent { DesignSystem { + // What the row should open to: the label's width (when wider than the circle) + // plus the inset on each side, measured with the style the row draws it in. + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val style = CodeTheme.typography.caption + labelWidth = leadingLabel?.let { + with(density) { measurer.measure(it, style, maxLines = 1).size.width.toDp() } + } ?: 0.dp + circleRevealWidth = CodeTheme.dimens.staticGrid.x10 + CodeTheme.dimens.inset * 2 Box(Modifier.width(360.dp)) { SwipeActionRow( actions = listOf( @@ -48,7 +71,14 @@ class SwipeActionRowTest { background = Color.Blue, onTriggered = { leadingFired++ }, resetOnDismiss = true, - ) {} + label = leadingLabel, + ) { + Box( + Modifier.fillMaxSize().semantics { + contentDescription = LEADING_DESCRIPTION + } + ) + } ) } else { emptyList() @@ -96,6 +126,46 @@ class SwipeActionRowTest { assertEquals(0.dp, rowLeft()) } + // The device path again, on a labelled action: a slow drag opens the row wide enough for the + // label, the circle keeps its content description and the label adds no node of its own, a + // tap on the revealed action fires it, and a full swipe still fires and comes home. + @Test + fun `a labelled leading action opens to fit its label and still fires`() { + show(withLeading = true, leadingLabel = LABEL) + assert(labelWidth > 50.dp) { "the test label ($labelWidth) should be wider than the circle" } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + // Past half the reveal, short of a full swipe. + swipeRight(startX = left + 1f, endX = left + labelWidth.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + val revealed = rowLeft() + assert(revealed > circleRevealWidth) { "revealed $revealed should be wider than the circle-only $circleRevealWidth" } + assert(revealed >= labelWidth) { "revealed $revealed should fit the label $labelWidth" } + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithContentDescription(LEADING_DESCRIPTION).assertExists() + composeTestRule.onAllNodesWithText(LABEL).assertCountEquals(0) + + composeTestRule.onNodeWithContentDescription(LEADING_DESCRIPTION).performTouchInput { click() } + composeTestRule.waitForIdle() + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 60.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + + assertEquals(2, leadingFired) + assertEquals(0, trailingFired) + assertEquals(0.dp, rowLeft()) + } + @Test fun `a row with only trailing actions does not move on a swipe right`() { show(withLeading = false) @@ -110,5 +180,7 @@ class SwipeActionRowTest { private companion object { const val ROW = "row" + const val LABEL = "Mark as Unread" + const val LEADING_DESCRIPTION = "Mark chat as unread" } } diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 4f27fe9d4a..09658e0013 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -2,6 +2,7 @@ package com.getcode.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -27,6 +28,7 @@ import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -47,8 +49,12 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem @@ -72,6 +78,10 @@ import org.jetbrains.compose.resources.painterResource * @param resetOnDismiss When true, a full swipe triggers [onTriggered] and animates * the row back instead of settling at the dismissed position. Callbacks used with * this flag should be idempotent (e.g. clipboard writes). + * @param label Optional title drawn under the circle in small secondary text. With a label the + * action is a circle with its title under it, and its slot widens to fit the title; without + * one it fills the row's height as before. The label is not announced: the icon's content + * description already names the action. * @param content Composable content displayed inside the action button (typically an [Icon]). */ @Stable @@ -79,6 +89,7 @@ class SwipeAction( val background: Color, val onTriggered: () -> Unit, val resetOnDismiss: Boolean = false, + val label: String? = null, val content: @Composable () -> Unit, ) @@ -170,10 +181,19 @@ fun SwipeActionRow( val actionPadding = CodeTheme.dimens.inset val minActionSize = CodeTheme.dimens.staticGrid.x10 - // Each action slot = minSize + padding on each side. - // Total reveal = N slots + one extra padding on the outer edge. - val totalRevealWidth = (minActionSize + actionPadding) * actions.size + actionPadding - val totalRevealWidthPx = with(density) { totalRevealWidth.toPx() } + // Each action slot = its width + padding on each side, where the width is the circle's unless + // a label is wider. Total reveal = N slots + one extra padding on the outer edge. + val labelStyle = CodeTheme.typography.caption + val textMeasurer = rememberTextMeasurer() + val trailingLabelWidths = actions.map { action -> + action.label?.let { textMeasurer.measure(it, labelStyle, maxLines = 1).size.width } + } + val leadingLabelWidths = leadingActions.map { action -> + action.label?.let { textMeasurer.measure(it, labelStyle, maxLines = 1).size.width } + } + val totalRevealWidthPx = with(density) { + revealWidthPx(trailingLabelWidths, minActionSize.toPx(), actionPadding.toPx()) + } val dismissAction = actions.lastOrNull() val currentDismissCallback by rememberUpdatedState(dismissAction?.onTriggered ?: {}) @@ -182,7 +202,7 @@ fun SwipeActionRow( var pendingResetCallback by remember(stateKey) { mutableStateOf(false) } val leadingRevealWidthPx = with(density) { - ((minActionSize + actionPadding) * leadingActions.size + actionPadding).toPx() + revealWidthPx(leadingLabelWidths, minActionSize.toPx(), actionPadding.toPx()) } val leadingDismissAction = leadingActions.lastOrNull() val currentLeadingCallback by rememberUpdatedState(leadingDismissAction?.onTriggered ?: {}) @@ -320,18 +340,10 @@ fun SwipeActionRow( Layout( content = { - actions.forEachIndexed { index, action -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(action.background) - .clickable { - action.onTriggered() - scope.launch { state.animateTo(SwipeState.Settled) } - }, - contentAlignment = Alignment.Center, - ) { - action.content() + actions.forEach { action -> + SwipeActionSlot(action, labelStyle, minActionSize) { + action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } } } }, @@ -343,32 +355,34 @@ fun SwipeActionRow( val n = measurables.size val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) - // Non-dismiss actions each occupy a fixed slot: padding + minSize. - // The dismiss action (last) gets the remaining width and grows on full swipe. - val fixedSlotWidth = minPx + padPx - val nonDismissTotal = (n - 1) * fixedSlotWidth + // Non-dismiss actions each occupy a fixed slot: padding + their width (the circle's, + // or the label's when wider). The dismiss action (last) gets the remaining width and + // grows on full swipe. + val slots = trailingLabelWidths.map { slotWidthPx(it, minPx) } + val nonDismissTotal = slots.dropLast(1).sumOf { it + padPx } val dismissAvailable = (absOffset - padPx - nonDismissTotal).coerceAtLeast(0f) - val dismissW = (dismissAvailable.toInt() - padPx).coerceAtLeast(minPx) + val dismissW = (dismissAvailable.toInt() - padPx).coerceAtLeast(slots.lastOrNull() ?: minPx) val placeables = measurables.mapIndexed { index, measurable -> - val w = if (index == n - 1) dismissW else minPx - measurable.measure(Constraints.fixed(w, actionH)) + val w = if (index == n - 1) dismissW else slots[index] + // A labelled action lays out its circle and title in the row's full height. + val h = if (trailingLabelWidths[index] != null) constraints.maxHeight else actionH + measurable.measure(Constraints.fixed(w, h)) } layout(constraints.maxWidth, constraints.maxHeight) { // Hidden, and so not tappable, while the leading side is open. if (state.offset > 0f || n == 0) return@layout - val yCenter = (constraints.maxHeight - actionH) / 2 var x = constraints.maxWidth - padPx // Dismiss action (rightmost) x -= placeables.last().width - placeables.last().place(x, yCenter) + placeables.last().place(x, (constraints.maxHeight - placeables.last().height) / 2) // Remaining actions, right to left for (i in n - 2 downTo 0) { x -= padPx + placeables[i].width - placeables[i].place(x, yCenter) + placeables[i].place(x, (constraints.maxHeight - placeables[i].height) / 2) } } } @@ -377,17 +391,9 @@ fun SwipeActionRow( Layout( content = { leadingActions.forEach { action -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(action.background) - .clickable { - action.onTriggered() - scope.launch { state.animateTo(SwipeState.Settled) } - }, - contentAlignment = Alignment.Center, - ) { - action.content() + SwipeActionSlot(action, labelStyle, minActionSize) { + action.onTriggered() + scope.launch { state.animateTo(SwipeState.Settled) } } } }, @@ -399,21 +405,21 @@ fun SwipeActionRow( val minPx = minActionSize.roundToPx() val n = measurables.size val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) - val fixedSlotWidth = minPx + padPx - val outerAvailable = (offset - padPx - (n - 1) * fixedSlotWidth).coerceAtLeast(0f) - val outerW = (outerAvailable.toInt() - padPx).coerceAtLeast(minPx) + val slots = leadingLabelWidths.map { slotWidthPx(it, minPx) } + val outerAvailable = (offset - padPx - slots.dropLast(1).sumOf { it + padPx }).coerceAtLeast(0f) + val outerW = (outerAvailable.toInt() - padPx).coerceAtLeast(slots.last()) val placeables = measurables.mapIndexed { index, measurable -> - measurable.measure(Constraints.fixed(if (index == n - 1) outerW else minPx, actionH)) + val h = if (leadingLabelWidths[index] != null) constraints.maxHeight else actionH + measurable.measure(Constraints.fixed(if (index == n - 1) outerW else slots[index], h)) } layout(constraints.maxWidth, constraints.maxHeight) { if (offset <= 0f) return@layout - val yCenter = (constraints.maxHeight - actionH) / 2 var x = padPx - placeables.last().place(x, yCenter) + placeables.last().place(x, (constraints.maxHeight - placeables.last().height) / 2) x += placeables.last().width for (i in n - 2 downTo 0) { x += padPx - placeables[i].place(x, yCenter) + placeables[i].place(x, (constraints.maxHeight - placeables[i].height) / 2) x += placeables[i].width } } @@ -435,6 +441,79 @@ fun SwipeActionRow( } } +/** An action's slot width: the circle's, or its label's when the label is wider. */ +private fun slotWidthPx(labelWidthPx: Int?, circlePx: Int): Int = maxOf(circlePx, labelWidthPx ?: 0) + +/** The reveal width for [labelWidths] (one per action, null when unlabelled), as a float like the anchors. */ +private fun revealWidthPx(labelWidths: List, circlePx: Float, padPx: Float): Float = + labelWidths.sumOf { maxOf(circlePx, (it ?: 0).toFloat()).toDouble() }.toFloat() + + padPx * (labelWidths.size + 1) + +/** + * One action behind the row. Unlabelled, it is the pill the row measures it to. Labelled, it is a + * circle with the label centred under it, the pair centred in the row's height; when the outermost + * action is stretched by a full swipe the circle widens by the same amount and the label stays + * under it. The whole slot, label included, takes the tap; the ripple stays on the circle. + */ +@Composable +private fun SwipeActionSlot( + action: SwipeAction, + labelStyle: TextStyle, + circleSize: Dp, + onClick: () -> Unit, +) { + val label = action.label + if (label == null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + action.content() + } + return + } + val interactions = remember { MutableInteractionSource() } + val gap = CodeTheme.dimens.grid.x1 + Layout( + content = { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .indication(interactions, ripple()), + contentAlignment = Alignment.Center, + ) { + action.content() + } + Text( + text = label, + style = labelStyle, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + softWrap = false, + modifier = Modifier.clearAndSetSemantics {}, + ) + }, + modifier = Modifier.clickable(interactionSource = interactions, indication = null, onClick = onClick), + ) { measurables, constraints -> + val circlePx = circleSize.roundToPx() + val gapPx = gap.roundToPx() + val text = measurables[1].measure(Constraints()) + val slot = slotWidthPx(text.width, circlePx) + val circle = measurables[0].measure( + Constraints.fixed(circlePx + (constraints.maxWidth - slot).coerceAtLeast(0), circlePx) + ) + layout(constraints.maxWidth, constraints.maxHeight) { + val top = (constraints.maxHeight - circle.height - gapPx - text.height) / 2 + circle.place((constraints.maxWidth - circle.width) / 2, top) + text.place((constraints.maxWidth - text.width) / 2, top + circle.height + gapPx) + } + } +} + @Preview @Composable private fun SwipeActionRowPreview() { From 8a2cf446a682227ab56af21caf5e3874923e88b8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 20:21:34 -0500 Subject: [PATCH 10/16] feat(ui): draw labelled swipe actions as pills, short Read/Unread labels A labelled SwipeAction now draws its tonal background as a pill the circle's height and 1.4x its width, with the label under it, as iOS draws a titled swipe action. Unlabelled actions stay circles, so the profile and region rows don't change. The chat list's leading action is labelled "Unread"/"Read"; the content description keeps "Mark as Unread"/"Mark as Read". --- .../src/androidMain/res/values/strings.xml | 2 ++ .../internal/components/ChatSelection.kt | 8 +++-- .../ui/components/SwipeActionRowTest.kt | 35 +++++++++++++++++-- .../getcode/ui/components/SwipeActionRow.kt | 31 +++++++++++----- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index e9f643dfd3..86418167d2 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1365,6 +1365,8 @@ Mark as Unread Mark as Read + Unread + Read Read All Select Chats Mute diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt index 5a1a1d149c..c02de36ce2 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/ChatSelection.kt @@ -1,5 +1,7 @@ package com.flipcash.app.tipping.internal.components +import com.flipcash.core.resources.action_read +import com.flipcash.core.resources.action_unread import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.remember import com.flipcash.services.models.chat.ChatId @@ -39,11 +41,13 @@ internal fun rememberSelectCircles(selection: Set?): SelectCircles { /** * The leading swipe on a chat list row: Mark as Read on an unread chat, Mark as Unread otherwise. * Tonal like the trailing Mute circle: the glyph in the unread blue on its dark-blue ground, sized - * like the trailing Archive and Mute circles, with the select-mode bar's wording under it. + * like the trailing Archive and Mute circles. The title under it is the short Read / Unread; the + * content description keeps the long Mark as Read / Mark as Unread. */ @Composable internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pair { val label = stringResource(if (isUnread) Res.string.action_markAsRead else Res.string.action_markAsUnread) + val title = stringResource(if (isUnread) Res.string.action_read else Res.string.action_unread) val painter = painterResource( if (isUnread) UiRes.drawable.ic_mark_chat_read else UiRes.drawable.ic_mark_chat_unread ) @@ -53,7 +57,7 @@ internal fun markReadSwipeAction(isUnread: Boolean, onTriggered: () -> Unit): Pa // The row stays in the list, so a full swipe has to send it home rather than leave it // settled off screen. resetOnDismiss = true, - label = label, + label = title, ) { Icon( painter = painter, diff --git a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt index a3e00e5342..c88a4b8c65 100644 --- a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt +++ b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt @@ -47,6 +47,7 @@ class SwipeActionRowTest { private var trailingFired = 0 private var labelWidth = 0.dp private var circleRevealWidth = 0.dp + private var pillRevealWidth = 0.dp private fun show(withLeading: Boolean, leadingLabel: String? = null) { composeTestRule.setContent { @@ -60,6 +61,8 @@ class SwipeActionRowTest { with(density) { measurer.measure(it, style, maxLines = 1).size.width.toDp() } } ?: 0.dp circleRevealWidth = CodeTheme.dimens.staticGrid.x10 + CodeTheme.dimens.inset * 2 + // A labelled action is a pill 1.4 times the circle's width. + pillRevealWidth = CodeTheme.dimens.staticGrid.x10 * 1.4f + CodeTheme.dimens.inset * 2 Box(Modifier.width(360.dp)) { SwipeActionRow( actions = listOf( @@ -132,7 +135,7 @@ class SwipeActionRowTest { @Test fun `a labelled leading action opens to fit its label and still fires`() { show(withLeading = true, leadingLabel = LABEL) - assert(labelWidth > 50.dp) { "the test label ($labelWidth) should be wider than the circle" } + assert(labelWidth > 70.dp) { "the test label ($labelWidth) should be wider than the pill" } composeTestRule.onNodeWithTag(ROW).performTouchInput { // Past half the reveal, short of a full swipe. @@ -178,9 +181,37 @@ class SwipeActionRowTest { assertEquals(0.dp, rowLeft()) } + // The chat list's leading title is short, so a labelled leading action opens to the pill's + // width (1.4 times the circle, as iOS draws a titled action) and no wider, and its swipe still + // fires on the real gesture sequence. + @Test + fun `a short label opens the reveal to the pill width`() { + show(withLeading = true, leadingLabel = SHORT_LABEL) + assert(labelWidth <= 70.dp) { "the short label ($labelWidth) should fit inside the pill" } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = left + 80.dp.toPx(), durationMillis = 1_500) + } + composeTestRule.waitForIdle() + val revealed = rowLeft() + assert(revealed > circleRevealWidth) { "revealed $revealed should be wider than the circle-only $circleRevealWidth" } + assertEquals(pillRevealWidth.value, revealed.value, 1f) + assertEquals(0, leadingFired) + + composeTestRule.onNodeWithTag(ROW).performTouchInput { + swipeRight(startX = left + 1f, endX = right, durationMillis = 120) + } + composeTestRule.waitForIdle() + assertEquals(1, leadingFired) + assertEquals(0.dp, rowLeft()) + } + private companion object { const val ROW = "row" - const val LABEL = "Mark as Unread" + // Wider than the circle, as the Members row's titles are. + const val LABEL = "Lift Restriction" + // The chat list's leading title: no wider than the circle. + const val SHORT_LABEL = "Unread" const val LEADING_DESCRIPTION = "Mark chat as unread" } } diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 09658e0013..0448183000 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlin.math.roundToInt import org.jetbrains.compose.resources.painterResource /** @@ -441,19 +442,32 @@ fun SwipeActionRow( } } -/** An action's slot width: the circle's, or its label's when the label is wider. */ -private fun slotWidthPx(labelWidthPx: Int?, circlePx: Int): Int = maxOf(circlePx, labelWidthPx ?: 0) +/** + * A labelled action draws its background as a pill: the circle's height and [PillWidthRatio] times + * its width, matching the titled swipe actions iOS draws. + */ +private const val PillWidthRatio = 1.4f + +private fun pillWidthPx(circlePx: Float): Float = circlePx * PillWidthRatio + +/** + * An action's slot width: the circle's when unlabelled; when labelled, the pill's, or the label's + * when the label is wider. + */ +private fun slotWidthPx(labelWidthPx: Int?, circlePx: Int): Int = + if (labelWidthPx == null) circlePx else maxOf(pillWidthPx(circlePx.toFloat()).roundToInt(), labelWidthPx) /** The reveal width for [labelWidths] (one per action, null when unlabelled), as a float like the anchors. */ private fun revealWidthPx(labelWidths: List, circlePx: Float, padPx: Float): Float = - labelWidths.sumOf { maxOf(circlePx, (it ?: 0).toFloat()).toDouble() }.toFloat() + + labelWidths.sumOf { slotWidthPx(it, circlePx.roundToInt()).toDouble() }.toFloat() + padPx * (labelWidths.size + 1) /** - * One action behind the row. Unlabelled, it is the pill the row measures it to. Labelled, it is a - * circle with the label centred under it, the pair centred in the row's height; when the outermost - * action is stretched by a full swipe the circle widens by the same amount and the label stays - * under it. The whole slot, label included, takes the tap; the ripple stays on the circle. + * One action behind the row. Unlabelled, it is the circle (or, stretched by a full swipe, the pill) + * the row measures it to. Labelled, it is a pill the circle's height and [PillWidthRatio] times as + * wide, with the label centred under it and the pair centred in the row's height; when the + * outermost action is stretched by a full swipe the pill widens by the same amount and the label + * stays under it. The whole slot, label included, takes the tap; the ripple stays on the pill. */ @Composable private fun SwipeActionSlot( @@ -503,8 +517,9 @@ private fun SwipeActionSlot( val gapPx = gap.roundToPx() val text = measurables[1].measure(Constraints()) val slot = slotWidthPx(text.width, circlePx) + val pillPx = pillWidthPx(circlePx.toFloat()).roundToInt() val circle = measurables[0].measure( - Constraints.fixed(circlePx + (constraints.maxWidth - slot).coerceAtLeast(0), circlePx) + Constraints.fixed(pillPx + (constraints.maxWidth - slot).coerceAtLeast(0), circlePx) ) layout(constraints.maxWidth, constraints.maxHeight) { val top = (constraints.maxHeight - circle.height - gapPx - text.height) / 2 From 75b453cce8a3062490c7fc84bb1b7323f3013b5b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 20:57:33 -0500 Subject: [PATCH 11/16] feat(messenger): label the Members remove swipe "Remove" The row is already in the group, so the swipe label drops "from Group". The member sheet keeps "Remove from Group" and the content description is unchanged. --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 1 + .../app/messenger/internal/screens/profile/MembersScreen.kt | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 86418167d2..a681f9af57 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -998,6 +998,7 @@ We were unable to unban this member. Please try again Restrict Remove from Group + Remove In %1$s diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt index ccf49fc743..2b7b668c6c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -273,10 +273,11 @@ internal fun MemberSwipeRow( val restrictLabel = stringResource(R.string.content_description_restrictMember) val liftLabel = stringResource(R.string.content_description_liftRestriction) val removeLabel = stringResource(R.string.content_description_removeMember) - // The member sheet's titles for the same actions, drawn under the circles. + // Labels drawn under the swipe pills: the member sheet's titles, with Remove + // shortened since the row is already in the group. val restrictTitle = stringResource(R.string.action_silenceMemberShort) val liftTitle = stringResource(R.string.action_liftRestriction) - val removeTitle = stringResource(R.string.action_removeFromGroup) + val removeTitle = stringResource(R.string.action_removeMemberShort) val swipeActions = buildList { if (actions.restrict) { add( From 03eb0b39048280ed6274015b16482bdda5faabb1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 2 Nov 2026 21:27:31 -0500 Subject: [PATCH 12/16] refactor(messenger): reuse action_remove for the Members swipe label --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 1 - .../app/messenger/internal/screens/profile/MembersScreen.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index a681f9af57..86418167d2 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -998,7 +998,6 @@ We were unable to unban this member. Please try again Restrict Remove from Group - Remove In %1$s diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt index 2b7b668c6c..9045ac81a1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -277,7 +277,7 @@ internal fun MemberSwipeRow( // shortened since the row is already in the group. val restrictTitle = stringResource(R.string.action_silenceMemberShort) val liftTitle = stringResource(R.string.action_liftRestriction) - val removeTitle = stringResource(R.string.action_removeMemberShort) + val removeTitle = stringResource(R.string.action_remove) val swipeActions = buildList { if (actions.restrict) { add( From cdad8ff9ff9c7f75f5f5f312e2c8d2a5aef8de27 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 02:44:49 -0500 Subject: [PATCH 13/16] fix(moderation): use the crossed-out mic for Restrict Restrict stops a member posting; a muted speaker read as "I can't hear them", closer to Mute. Covers the Members swipe, member sheet, profile menu, Silenced Members entry and the message action. Drops the now unused SpeakerSlash vector. --- .../internal/screens/components/ChatTopBar.kt | 4 +- .../screens/profile/GroupProfileScreen.kt | 4 +- .../internal/screens/profile/MemberSheet.kt | 4 +- .../internal/screens/profile/MembersScreen.kt | 4 +- .../screens/profile/ProfileMenuItems.kt | 4 +- .../util/resources/icons/SpeakerSlash.kt | 49 ------------------- 6 files changed, 10 insertions(+), 59 deletions(-) delete mode 100644 ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 2de3c84bad..6be075458e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -80,7 +80,7 @@ import com.getcode.ui.core.measured import com.getcode.ui.core.noRippleClickable import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff @Composable internal fun ChatTopBar( @@ -446,7 +446,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_silenceMember, firstName), - icon = SpeakerSlash, + icon = Icons.Outlined.MicOff, testTag = "action_silence_member", onClick = onSilence, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index e7f5bb8e9f..e5fd7f0c82 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.material.icons.filled.Lock import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.Dp -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff import dev.chrisbanes.haze.HazeState import androidx.compose.ui.text.style.TextAlign import kotlin.time.Duration.Companion.minutes @@ -571,7 +571,7 @@ internal fun GroupProfileMenu( } if (item == GroupProfileMenuAction.Moderation) { Submenu(label = stringResource(item.labelRes()), icon = item.icon(isPrivate)) { - ModerationEntry(R.string.title_silencedMembers, SpeakerSlash) { + ModerationEntry(R.string.title_silencedMembers, Icons.Outlined.MicOff) { onDismiss() onSilenced() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt index a281d19cb0..dbd0030beb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MemberSheet.kt @@ -32,7 +32,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.ChoiceRow import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff import kotlin.time.Instant /** @@ -138,7 +138,7 @@ internal fun MemberSheetContent( if (actions.restrict) { ChoiceRow( label = stringResource(R.string.action_silenceMemberShort), - icon = rememberVectorPainter(SpeakerSlash), + icon = rememberVectorPainter(Icons.Outlined.MicOff), contentColor = CodeTheme.colors.warningText, onClick = onRestrict, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt index 9045ac81a1..d65a5af2de 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -61,7 +61,7 @@ import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.components.SwipeRevealGroup import com.getcode.ui.components.rememberSwipeRevealGroup import com.getcode.ui.theme.CodeScaffold -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff /** * Node 11210:70 — every member of a private group: the creator pinned first with a Host tag, then @@ -287,7 +287,7 @@ internal fun MemberSwipeRow( resetOnDismiss = true, label = restrictTitle, ) { - SwipeIcon(SpeakerSlash, restrictLabel, CodeTheme.colors.warning) + SwipeIcon(Icons.Outlined.MicOff, restrictLabel, CodeTheme.colors.warning) } ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index ff3eab1aab..82fe9e34be 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -24,7 +24,7 @@ import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge -import com.getcode.util.resources.icons.SpeakerSlash +import androidx.compose.material.icons.outlined.MicOff /** * What a DM counterparty's profile can act on. @@ -74,7 +74,7 @@ internal fun ChatProfileAction.icon(): ImageVector = when (this) { ChatProfileAction.Report -> Icons.Outlined.Feedback ChatProfileAction.Block -> Icons.Outlined.Block ChatProfileAction.Unblock -> Icons.Outlined.CheckCircle - ChatProfileAction.Silence -> SpeakerSlash + ChatProfileAction.Silence -> Icons.Outlined.MicOff ChatProfileAction.Remove -> Icons.Outlined.PersonRemove } diff --git a/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt b/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt deleted file mode 100644 index 9c086f473b..0000000000 --- a/ui/resources/src/commonMain/kotlin/com/getcode/util/resources/icons/SpeakerSlash.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.getcode.util.resources.icons - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp - -// Speaker with no sound waves and a diagonal slash, matching iOS's SF Symbol `speaker.slash`. -private var _SpeakerSlash: ImageVector? = null -val SpeakerSlash: ImageVector - get() { - if (_SpeakerSlash != null) { - return _SpeakerSlash!! - } - _SpeakerSlash = ImageVector.Builder( - name = "SpeakerSlash", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f - ).apply { - path( - fill = null, - fillAlpha = 1.0f, - stroke = SolidColor(Color(0xFFFFFFFF)), - strokeAlpha = 1.0f, - strokeLineWidth = 1.8f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero - ) { - moveTo(6f, 9.5f) - horizontalLineTo(9f) - lineTo(14f, 5f) - verticalLineTo(19f) - lineTo(9f, 14.5f) - horizontalLineTo(6f) - close() - moveTo(4f, 4f) - lineTo(20f, 20f) - } - }.build() - return _SpeakerSlash!! - } From 8b9e61d5d4992ee13586db874c9217192d811dc2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 11:53:18 -0500 Subject: [PATCH 14/16] feat(chat-list): archive takes the trailing full swipe The main list's trailing swipe now draws Mute inside and Archive outermost. SwipeActionRow fires the last action on a full swipe, so a full swipe now archives (with Undo) instead of opening the mute sheet. Mute keeps resetOnDismiss so the row closes under its sheet. The archived list is unchanged; it only has Unarchive. ChatSwipeRowTest replays the device gesture: a slow partial drag leaves both actions revealed with Archive at the trailing edge, then a full swipe archives once and does not mute. --- .../flipcash/app/tipping/ChatSwipeRowTest.kt | 95 +++++++++++++++++++ .../app/tipping/ChatsScreenContent.kt | 32 +++---- 2 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt new file mode 100644 index 0000000000..9066dc5dcd --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/ChatSwipeRowTest.kt @@ -0,0 +1,95 @@ +package com.flipcash.app.tipping + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.ui.components.rememberSwipeRevealGroup +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The main list's trailing swipe: mute inside, archive outermost, and a full swipe archives. + * The gesture is the one a device sees: a slow partial drag that leaves both actions revealed, + * then a fast full swipe from there. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ChatSwipeRowTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private var archived = 0 + private var muted = 0 + + private fun show() { + composeRule.setContent { + FlipcashPreview(showBackground = true) { + ChatSwipeRow( + isMuted = false, + isUnread = false, + swipeEnabled = true, + onToggleRead = {}, + onMute = { muted++ }, + onArchive = { archived++ }, + stateKey = "chat", + revealGroup = rememberSwipeRevealGroup(), + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + composeRule.waitForIdle() + } + + private fun rowLeft() = composeRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().left + + @Test + fun `archive is outermost and a full swipe from the revealed row archives`() { + show() + + composeRule.onNodeWithTag(ROW).performTouchInput { + swipeLeft(startX = right - 1f, endX = right - 120.dp.toPx(), durationMillis = 1_500) + } + composeRule.waitForIdle() + assertTrue(rowLeft() < 0.dp, "the short drag should leave the row revealed") + assertEquals(0, archived) + assertEquals(0, muted) + + val mute = composeRule.onNodeWithContentDescription(MUTE).getUnclippedBoundsInRoot() + val archive = composeRule.onNodeWithContentDescription(ARCHIVE).getUnclippedBoundsInRoot() + assertTrue(archive.left > mute.left, "archive should sit outside mute, at the trailing edge") + + composeRule.onNodeWithTag(ROW).performTouchInput { + swipeLeft(startX = right - 1f, endX = left, durationMillis = 120) + } + composeRule.waitForIdle() + + assertEquals(1, archived) + assertEquals(0, muted) + } + + private companion object { + const val ROW = "row" + const val MUTE = "Mute notifications" + const val ARCHIVE = "Archive chat" + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt index 3fdf6f0e24..ec728cee9d 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/ChatsScreenContent.kt @@ -404,9 +404,9 @@ private fun LazyListScope.tipChatItems( /** * One leading swipe action, Mark as Read or Mark as Unread (a full swipe fires it), and two - * trailing ones: 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. + * trailing ones: mute inside, archive outside. [SwipeActionRow] draws the last action + * outermost and fires it on a full swipe, so a full swipe archives and mute is a tap on the + * revealed action. * * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never @@ -417,7 +417,7 @@ private fun LazyListScope.tipChatItems( * closed row. */ @Composable -private fun ChatSwipeRow( +internal fun ChatSwipeRow( isMuted: Boolean, isUnread: Boolean, swipeEnabled: Boolean, @@ -445,18 +445,6 @@ private fun ChatSwipeRow( SwipeActionRow( leadingActions = listOf(readAction), actions = listOf( - SwipeAction( - background = CodeTheme.colors.surfaceVariant, - onTriggered = onArchive, - label = archiveTitle, - ) { - 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. @@ -476,6 +464,18 @@ private fun ChatSwipeRow( modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) }, + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onArchive, + label = archiveTitle, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = archiveLabel, + 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 same actions there. modifier = modifier.semantics { From 3bf73f4eaec25dbe1f5efc6c1d1100f535ceb6b4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 14:36:25 -0500 Subject: [PATCH 15/16] feat(ui): dim the inner swipe actions as a full swipe commits Past the reveal width, the actions that are not outermost fade linearly from full alpha to 0 at the point where releasing commits the full swipe (halfway from the reveal to the row's width, the drag's default positional threshold). On the chat list's trailing side Mute fades while Archive grows. Both sides use it; a side with one action is unchanged. The alpha is set in graphicsLayer from the drag offset, so dragging does not recompose, and it returns to 1 as the row springs back or a reset-on-dismiss sends it home. SwipeActionRowTest drags a two-action row slowly past the reveal and reads the inner action's pixels: opaque at the reveal, linear in the offset midway, nearly gone at the threshold, and opaque again after a release short of it. With the fade disabled the midway check reads 1.0. --- .../ui/components/SwipeActionRowTest.kt | 84 +++++++++++++++++++ .../getcode/ui/components/SwipeActionRow.kt | 40 +++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt index c88a4b8c65..09fe9e4b63 100644 --- a/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt +++ b/ui/components/src/androidHostTest/kotlin/com/getcode/ui/components/SwipeActionRowTest.kt @@ -1,5 +1,6 @@ package com.getcode.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -12,6 +13,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.click import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule @@ -21,14 +23,18 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem +import androidx.compose.ui.graphics.toPixelMap import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.GraphicsMode +import kotlin.math.abs import kotlin.test.assertEquals /** @@ -206,6 +212,83 @@ class SwipeActionRowTest { assertEquals(0.dp, rowLeft()) } + // A slow drag past the reveal toward a full swipe: the inner action fades with the offset, from + // full at the reveal to nearly gone at the point a release would commit, and is back to full + // once the row is released short of that point and settles revealed. + @Test + fun `the inner trailing action fades as a full swipe commits and returns on release`() { + var revealWidth = 0.dp + composeTestRule.setContent { + DesignSystem { + revealWidth = CodeTheme.dimens.staticGrid.x10 * 2 + CodeTheme.dimens.inset * 3 + Box(Modifier.fillMaxWidth().background(Color.Black)) { + SwipeActionRow( + actions = listOf( + SwipeAction(background = Color.Red, onTriggered = {}) { + Box(Modifier.fillMaxSize().semantics { contentDescription = INNER }) + }, + SwipeAction(background = Color.Gray, onTriggered = { trailingFired++ }) {}, + ), + ) { + Box(Modifier.fillMaxWidth().height(72.dp).testTag(ROW)) + } + } + } + } + // Releasing past halfway from the reveal to the row's width commits the full swipe. + val rowWidth = composeTestRule.onNodeWithTag(ROW).getUnclippedBoundsInRoot().let { it.right - it.left } + val threshold = revealWidth + (rowWidth - revealWidth) / 2 + fun innerRed(): Float { + val pixels = composeTestRule.onNodeWithContentDescription(INNER).captureToImage().toPixelMap() + return pixels[pixels.width / 2, pixels.height / 2].red + } + fun opened() = -rowLeft() + // A slow drag in small steps, steered by where the row actually is: injected moves can + // land a frame late, so each step is sized from the measured offset. The first step also + // covers the touch slop. Moved by deltas because the node's bounds move with the row. + var started = false + fun openTo(open: Dp) { + repeat(200) { + val remaining = open - opened() + if (abs(remaining.value) < 1f) return + val step = remaining.coerceIn(-4.dp, 4.dp) + composeTestRule.onNodeWithTag(ROW).performTouchInput { + val slop = if (started) 0f else viewConfiguration.touchSlop + moveBy(androidx.compose.ui.geometry.Offset(-(slop + step.toPx()), 0f)) + } + started = true + composeTestRule.waitForIdle() + } + } + + composeTestRule.onNodeWithTag(ROW).performTouchInput { down(androidx.compose.ui.geometry.Offset(right - 1f, centerY)) } + openTo(revealWidth) + composeTestRule.waitForIdle() + assertEquals(revealWidth.value, opened().value, 2f) + assert(innerRed() > 0.95f) { "at the reveal the inner action should be opaque, red ${innerRed()}" } + + val midway = revealWidth + (threshold - revealWidth) / 2 + openTo(midway) + composeTestRule.waitForIdle() + // Linear in how far past the reveal the row is, measured on the row itself. + val expected = 1f - (opened() - revealWidth) / (threshold - revealWidth) + assert(expected in 0.2f..0.8f) { "midway drag opened ${opened()}, expected alpha $expected" } + assertEquals(expected, innerRed(), 0.05f) + + openTo(threshold - 2.dp) + composeTestRule.waitForIdle() + assertEquals((threshold - 2.dp).value, opened().value, 2f) + assert(innerRed() < 0.1f) { "near the threshold the inner action should be nearly gone, red ${innerRed()}" } + + // Back short of the threshold and let go: the row settles revealed, the action opaque again. + openTo(revealWidth + 10.dp) + composeTestRule.onNodeWithTag(ROW).performTouchInput { up() } + composeTestRule.waitForIdle() + assertEquals(revealWidth.value, opened().value, 2f) + assert(innerRed() > 0.95f) { "after release the inner action should be opaque, red ${innerRed()}" } + assertEquals(0, trailingFired) + } + private companion object { const val ROW = "row" // Wider than the circle, as the Members row's titles are. @@ -213,5 +296,6 @@ class SwipeActionRowTest { // The chat list's leading title: no wider than the circle. const val SHORT_LABEL = "Unread" const val LEADING_DESCRIPTION = "Mark chat as unread" + const val INNER = "Inner action" } } diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt index 0448183000..5f128e36c7 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged @@ -341,8 +342,16 @@ fun SwipeActionRow( Layout( content = { - actions.forEach { action -> - SwipeActionSlot(action, labelStyle, minActionSize) { + actions.forEachIndexed { index, action -> + // The inner actions fade as a full swipe commits; the outermost grows instead. + val fade = if (index < actions.lastIndex) { + Modifier.graphicsLayer { + alpha = innerActionAlpha(-state.offset, totalRevealWidthPx, rowWidthPx) + } + } else { + Modifier + } + SwipeActionSlot(action, labelStyle, minActionSize, fade) { action.onTriggered() scope.launch { state.animateTo(SwipeState.Settled) } } @@ -391,8 +400,15 @@ fun SwipeActionRow( if (hasLeading) { Layout( content = { - leadingActions.forEach { action -> - SwipeActionSlot(action, labelStyle, minActionSize) { + leadingActions.forEachIndexed { index, action -> + val fade = if (index < leadingActions.lastIndex) { + Modifier.graphicsLayer { + alpha = innerActionAlpha(state.offset, leadingRevealWidthPx, rowWidthPx) + } + } else { + Modifier + } + SwipeActionSlot(action, labelStyle, minActionSize, fade) { action.onTriggered() scope.launch { state.animateTo(SwipeState.Settled) } } @@ -457,6 +473,17 @@ private fun pillWidthPx(circlePx: Float): Float = circlePx * PillWidthRatio private fun slotWidthPx(labelWidthPx: Int?, circlePx: Int): Int = if (labelWidthPx == null) circlePx else maxOf(pillWidthPx(circlePx.toFloat()).roundToInt(), labelWidthPx) +/** + * The alpha of an action that is not the outermost, for a side open by [openPx]: 1 up to the + * reveal width, falling linearly to 0 at the point past which releasing commits the full swipe + * (halfway from the reveal to the row's width, the drag's default positional threshold). + */ +internal fun innerActionAlpha(openPx: Float, revealPx: Float, rowWidthPx: Float): Float { + val span = (rowWidthPx - revealPx) / 2f + if (span <= 0f) return 1f + return 1f - ((openPx - revealPx) / span).coerceIn(0f, 1f) +} + /** The reveal width for [labelWidths] (one per action, null when unlabelled), as a float like the anchors. */ private fun revealWidthPx(labelWidths: List, circlePx: Float, padPx: Float): Float = labelWidths.sumOf { slotWidthPx(it, circlePx.roundToInt()).toDouble() }.toFloat() + @@ -474,12 +501,13 @@ private fun SwipeActionSlot( action: SwipeAction, labelStyle: TextStyle, circleSize: Dp, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { val label = action.label if (label == null) { Box( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(50)) .background(action.background) .clickable(onClick = onClick), @@ -511,7 +539,7 @@ private fun SwipeActionSlot( modifier = Modifier.clearAndSetSemantics {}, ) }, - modifier = Modifier.clickable(interactionSource = interactions, indication = null, onClick = onClick), + modifier = modifier.clickable(interactionSource = interactions, indication = null, onClick = onClick), ) { measurables, constraints -> val circlePx = circleSize.roundToPx() val gapPx = gap.roundToPx() From a4e4266b9b4afd267400d0328997bec1b5f29655 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:01:42 -0500 Subject: [PATCH 16/16] feat(chat-list): fade the select circle in place while the row slides Entering select mode used to slide the circle in from the leading edge with the row. The circle now sits at its final position and fades in while the row content slides right to make room; leaving is the reverse. The slot still widens with the reveal progress and clips the circle, so the content sliding off uncovers the circle instead of overlapping it. One progress value drives the slot width and the alpha, read in layout and draw only. Toggling a circle in select mode is unchanged. SelectCircleRevealTest pins it: the circle's x stays constant across progress, the avatar moves by progress times the slot, and the disc's colour matches its alpha over the row background. --- .../internal/SelectCircleRevealTest.kt | 114 ++++++++++++++++++ .../tipping/internal/components/TipChatRow.kt | 30 +++-- 2 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt diff --git a/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt new file mode 100644 index 0000000000..f34ac61b8d --- /dev/null +++ b/apps/flipcash/features/chat-list/src/androidHostTest/kotlin/com/flipcash/app/tipping/internal/SelectCircleRevealTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.tipping.internal + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.tipping.internal.components.AVATAR_TAG +import com.flipcash.app.tipping.internal.components.SELECT_CIRCLE_TAG +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Select mode's enter and exit: the row content slides right by the circle's slot while the + * circle stays where it ends up and fades in, both driven by the one reveal progress. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class SelectCircleRevealTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun circleStaysPutAndFadesWhileContentSlides() { + val progress = mutableFloatStateOf(0f) + var background = Color.Unspecified + var indicator = Color.Unspecified + composeRule.setContent { + FlipcashPreview(showBackground = true) { + background = CodeTheme.colors.background + indicator = CodeTheme.colors.indicator + TipChatRow( + chat = ConversationReference( + chatId = ChatId(byteArrayOf(1)), + displayName = "Grace Hopper", + handle = null, + lastMessagePreview = "Hello", + lastActivity = Instant.fromEpochMilliseconds(1_700_000_000_000), + ), + // Checked, so the circle is a filled disc whose colour shows its alpha. + circleReveal = { progress.floatValue }, + circleChecked = true, + onClick = {}, + ) + } + } + + val density = composeRule.density + val tolerance = with(density) { 1.dp.toPx() } + + fun circleX() = composeRule.onNodeWithTag(SELECT_CIRCLE_TAG, useUnmergedTree = true) + .fetchSemanticsNode().positionInRoot.x + fun avatarX() = composeRule.onNodeWithTag(AVATAR_TAG, useUnmergedTree = true) + .fetchSemanticsNode().positionInRoot.x + + composeRule.waitForIdle() + val restingCircleX = circleX() + val restingAvatarX = avatarX() + + // The circle plus its gap to the avatar: the distance the content slides at full progress. + progress.floatValue = 1f + composeRule.waitForIdle() + val slot = avatarX() - restingAvatarX + assertTrue(slot > with(density) { 20.dp.toPx() }, "content slid only $slot px") + + for (p in listOf(0.25f, 0.5f, 0.75f, 1f)) { + progress.floatValue = p + composeRule.waitForIdle() + + assertEquals(restingCircleX, circleX(), tolerance, "circle moved at progress $p") + assertEquals(slot * p, avatarX() - restingAvatarX, tolerance, "content offset at progress $p") + + // 3dp into the disc, level with its centre: inside the circle and left of the content + // edge at every progress sampled here. + val node = composeRule.onNodeWithTag(SELECT_CIRCLE_TAG, useUnmergedTree = true).fetchSemanticsNode() + val x = (circleX() + with(density) { 3.dp.toPx() }).toInt() + val y = (node.positionInRoot.y + node.size.height / 2f).toInt() + val pixel = Color(composeRule.onRoot().captureToImage().asAndroidBitmap().getPixel(x, y)) + // Alpha composites each sRGB channel linearly over the row background. + fun mix(a: Float, b: Float) = a + (b - a) * p + val expected = Color( + mix(background.red, indicator.red), + mix(background.green, indicator.green), + mix(background.blue, indicator.blue), + ) + assertTrue( + abs(pixel.red - expected.red) < 0.03f && abs(pixel.green - expected.green) < 0.03f && + abs(pixel.blue - expected.blue) < 0.03f, + "circle colour at progress $p: ${Integer.toHexString(pixel.toArgb())}, " + + "expected ${Integer.toHexString(expected.toArgb())}", + ) + } + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 61c3d354f0..9acf0f6598 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.foundation.border import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag import androidx.compose.ui.layout.layout import kotlin.math.roundToInt import androidx.compose.foundation.layout.Arrangement @@ -50,6 +51,10 @@ import com.getcode.theme.CodeTheme import kotlin.time.Clock +/** Test tags for select mode's circle and the avatar it pushes right. */ +internal const val SELECT_CIRCLE_TAG = "chat_row_select_circle" +internal const val AVATAR_TAG = "chat_row_avatar" + @Composable internal fun TipChatRow( chat: ConversationReference, @@ -77,12 +82,13 @@ internal fun TipChatRow( Row(verticalAlignment = Alignment.CenterVertically) { SelectCircleSlot(circleReveal, circleChecked) ContactAvatar( - image = chat.image, - displayName = chat.name.orEmpty(), - access = chat.avatarAccess, modifier = Modifier + .testTag(AVATAR_TAG) .requiredSize(CodeTheme.dimens.staticGrid.x9) .clip(CircleShape), + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, ) } }, @@ -147,9 +153,11 @@ internal fun TipChatRow( } /** - * Select mode's leading circle. It slides in, pushing the row content right, only as select mode - * starts ([reveal] going 0 to 1), and back out as it ends; toggling a row changes the circle in - * place. Reads [reveal] in layout and draw only, so the slide doesn't recompose the row. + * Select mode's leading circle. As select mode starts ([reveal] going 0 to 1) the slot widens and + * pushes the row content right, while the circle stays at its final position and fades in; as it + * ends, the reverse. The slot clips the circle, so the content sliding off uncovers it rather than + * overlapping it. Toggling a row changes the circle in place. Reads [reveal] in layout and draw + * only, so the slide doesn't recompose the row. */ @Composable private fun SelectCircleSlot(reveal: () -> Float, checked: Boolean) { @@ -159,12 +167,16 @@ private fun SelectCircleSlot(reveal: () -> Float, checked: Boolean) { .layout { measurable, constraints -> val placeable = measurable.measure(constraints) val width = (placeable.width * reveal()).roundToInt() - layout(width, placeable.height) { placeable.placeRelative(width - placeable.width, 0) } + // Placed at its final position for every width, so only the content moves. + layout(width, placeable.height) { placeable.placeRelative(0, 0) } } .graphicsLayer { alpha = reveal() } ) { - // The gap to the avatar slides with the circle rather than popping in ahead of it. - Box(Modifier.padding(end = CodeTheme.dimens.staticGrid.x3)) { SelectCircle(checked) } + Box( + Modifier + .testTag(SELECT_CIRCLE_TAG) + .padding(end = CodeTheme.dimens.staticGrid.x3) + ) { SelectCircle(checked) } } }