diff --git a/build.gradle b/build.gradle index ee4b95d2f..829a69abd 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,7 @@ plugins { id 'com.android.application' version '8.9.1' apply false id 'com.android.library' version '8.9.1' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false id 'io.github.gradle-nexus.publish-plugin' version "1.2.0" id "org.jetbrains.dokka" version "1.8.10" id 'com.diffplug.spotless' version '8.0.0' apply false @@ -29,6 +30,9 @@ subprojects { target '**/*.kt' targetExclude '**/build/**', '**/generated/**', '**/xmtpv3.kt' ktlint('1.7.1') + suppressLintsFor { + shortCode = 'standard:max-line-length' + } } kotlinGradle { diff --git a/example/build.gradle b/example/build.gradle index 35f4b8a02..85c24dda3 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -8,6 +8,7 @@ buildscript { plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' // id 'com.google.gms.google-services' } @@ -43,11 +44,15 @@ android { jvmTarget = '17' } buildFeatures { - viewBinding true buildConfig true + compose true } } +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") +} + dependencies { implementation project(':library') implementation 'androidx.core:core-ktx:1.12.0' @@ -64,6 +69,28 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'org.web3j:crypto:5.0.0' + // Security - Encrypted SharedPreferences + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + // Glide for GIF support + implementation 'com.github.bumptech.glide:glide:4.16.0' + + // Jetpack Compose + def composeBom = platform('androidx.compose:compose-bom:2024.02.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' + implementation 'androidx.navigation:navigation-compose:2.7.6' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + // WalletConnect V2: core library + WalletConnectModal implementation(platform("com.walletconnect:android-bom:1.19.1")) implementation("com.walletconnect:android-core") diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index f09ae12de..bded0e098 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -63,6 +63,19 @@ + + + + (null) + var selectedLogLevel: FfiLogLevel? + get() = _selectedLogLevel.get() + set(value) = _selectedLogLevel.set(value) + + // Lock for thread-safe operations + private val lock = Any() + + // Application-scoped coroutine scope for client operations + private var managerScope: CoroutineScope? = null + + private fun getOrCreateScope(): CoroutineScope = + synchronized(lock) { + managerScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { + managerScope = it + } + } + fun clientOptions( appContext: Context, address: String, + environment: XMTPEnvironment = selectedEnvironment, ): ClientOptions { val keyUtil = KeyUtil(appContext) val encryptionKey = @@ -29,7 +62,7 @@ object ClientManager { return ClientOptions( api = ClientOptions.Api( - XMTPEnvironment.DEV, + environment, isSecure = true, ), appContext = appContext, @@ -55,32 +88,93 @@ object ClientManager { address: String, appContext: Context, ) { - if (clientState.value is ClientState.Ready) return - GlobalScope.launch(Dispatchers.IO) { + // Use compareAndSet to atomically transition from Unknown/Error to Creating + // This prevents race conditions with concurrent createClient() calls + synchronized(lock) { + val currentState = _clientState.value + if (currentState is ClientState.Ready || currentState is ClientState.Creating) return + _clientState.value = ClientState.Creating + } + getOrCreateScope().launch { try { + val keyUtil = KeyUtil(appContext) + val privateKeyBytes = keyUtil.retrievePrivateKey(address) + + // Restore the saved environment, default to DEV if not found + val savedEnvironment = keyUtil.retrieveEnvironment() + selectedEnvironment = savedEnvironment?.let { + try { + XMTPEnvironment.valueOf(it) + } catch (e: IllegalArgumentException) { + XMTPEnvironment.DEV + } + } ?: XMTPEnvironment.DEV + + Timber.d( + "createClient: address=$address, hasPrivateKey=${privateKeyBytes != null}, environment=$selectedEnvironment", + ) + _client = - Client.build( - PublicIdentity(IdentityKind.ETHEREUM, address), - clientOptions(appContext, address), - ) + if (privateKeyBytes != null) { + // Rebuild the wallet from stored private key and use Client.create() + // This allows signing if identity registration is needed + val privateKey = PrivateKeyBuilder.buildFromPrivateKeyData(privateKeyBytes) + val wallet = PrivateKeyBuilder(privateKey) + val rebuiltAddress = wallet.publicIdentity.identifier + Timber.d("createClient: rebuiltAddress=$rebuiltAddress") + + Client.create( + wallet, + clientOptions(appContext, rebuiltAddress), + ) + } else { + // No private key stored - this could happen on old installations + // Signal that we need to re-authenticate + throw IllegalStateException("No wallet key found. Please sign in again.") + } Client.register(codec = GroupUpdatedCodec()) + Client.register(codec = ReplyCodec()) + Client.register(codec = ReactionCodec()) + Client.register(codec = AttachmentCodec()) + Client.register(codec = RemoteAttachmentCodec()) _clientState.value = ClientState.Ready } catch (e: Exception) { + Timber.e(e, "createClient failed") _clientState.value = ClientState.Error(e.localizedMessage.orEmpty()) } } } + // Set a client that was created externally (e.g., during wallet generation) + fun setClient(client: Client) { + synchronized(lock) { + _client = client + Client.register(codec = GroupUpdatedCodec()) + Client.register(codec = ReplyCodec()) + Client.register(codec = ReactionCodec()) + Client.register(codec = AttachmentCodec()) + Client.register(codec = RemoteAttachmentCodec()) + _clientState.value = ClientState.Ready + } + } + @UiThread fun clearClient() { - _clientState.value = ClientState.Unknown - _client = null + synchronized(lock) { + _clientState.value = ClientState.Unknown + _client = null + // Cancel the scope to prevent memory leaks from pending coroutines + managerScope?.cancel() + managerScope = null + } } sealed class ClientState { - object Unknown : ClientState() + data object Unknown : ClientState() + + data object Creating : ClientState() - object Ready : ClientState() + data object Ready : ClientState() data class Error( val message: String, diff --git a/example/src/main/java/org/xmtp/android/example/ExampleApp.kt b/example/src/main/java/org/xmtp/android/example/ExampleApp.kt index a44348c4b..f65c95f4a 100644 --- a/example/src/main/java/org/xmtp/android/example/ExampleApp.kt +++ b/example/src/main/java/org/xmtp/android/example/ExampleApp.kt @@ -13,6 +13,12 @@ const val BASE_LOG_TAG = "WC2" class ExampleApp : Application() { override fun onCreate() { super.onCreate() + + // Initialize Timber for logging + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + val connectionType = ConnectionType.AUTOMATIC val relayUrl = "relay.walletconnect.com" val serverUrl = "wss://$relayUrl?projectId=${BuildConfig.PROJECT_ID}" diff --git a/example/src/main/java/org/xmtp/android/example/MainActivity.kt b/example/src/main/java/org/xmtp/android/example/MainActivity.kt index dac0315b3..39703b258 100644 --- a/example/src/main/java/org/xmtp/android/example/MainActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/MainActivity.kt @@ -9,18 +9,24 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -28,42 +34,53 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.xmtp.android.example.connect.ConnectWalletActivity import org.xmtp.android.example.conversation.ConversationDetailActivity -import org.xmtp.android.example.conversation.ConversationsAdapter -import org.xmtp.android.example.conversation.ConversationsClickListener -import org.xmtp.android.example.conversation.NewConversationBottomSheet -import org.xmtp.android.example.conversation.NewGroupBottomSheet -import org.xmtp.android.example.databinding.ActivityMainBinding -import org.xmtp.android.example.logs.LogViewerBottomSheet +import org.xmtp.android.example.conversation.NewConversationActivity import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager +import org.xmtp.android.example.ui.components.DrawerState +import org.xmtp.android.example.ui.screens.ConversationItem +import org.xmtp.android.example.ui.screens.MainScreen +import org.xmtp.android.example.ui.sheets.LogFileInfo +import org.xmtp.android.example.ui.sheets.LogViewerSheet +import org.xmtp.android.example.ui.sheets.WalletInfo +import org.xmtp.android.example.ui.sheets.WalletInfoSheet +import org.xmtp.android.example.ui.sheets.formatFileSize +import org.xmtp.android.example.ui.theme.XMTPTheme import org.xmtp.android.example.utils.KeyUtil import org.xmtp.android.library.Client -import org.xmtp.android.library.Conversation +import timber.log.Timber import uniffi.xmtpv3.FfiLogLevel import uniffi.xmtpv3.FfiLogRotation - -class MainActivity : - AppCompatActivity(), - ConversationsClickListener { +import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.libxmtp.Reply +import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() - private lateinit var binding: ActivityMainBinding private lateinit var accountManager: AccountManager - private lateinit var adapter: ConversationsAdapter - private var bottomSheet: NewConversationBottomSheet? = null - private var groupBottomSheet: NewGroupBottomSheet? = null - private var logsBottomSheet: LogViewerBottomSheet? = null private val REQUEST_CODE_POST_NOTIFICATIONS = 101 - // Add constant for SharedPreferences companion object { private const val PREFS_NAME = "XMTPPreferences" private const val KEY_LOGS_ACTIVATED = "logs_activated" + + // Push notification server - configured via build variants in production + private const val DEFAULT_PUSH_SERVER = "10.0.2.2:8080" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() accountManager = AccountManager.get(this) checkAndRequestPermissions() - PushNotificationTokenManager.init(this, "10.0.2.2:8080") + PushNotificationTokenManager.init(this, DEFAULT_PUSH_SERVER) viewModel.setupPush() val keys = KeyUtil(this).loadKeys() @@ -74,46 +91,30 @@ class MainActivity : ClientManager.createClient(keys, this) - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - - adapter = ConversationsAdapter(clickListener = this) - binding.list.layoutManager = LinearLayoutManager(this) - binding.list.adapter = adapter - binding.refresh.setOnRefreshListener { - if (ClientManager.clientState.value is ClientManager.ClientState.Ready) { - viewModel.fetchConversations() + setContent { + XMTPTheme { + MainActivityContent( + viewModel = viewModel, + onConversationClick = { conversationId, topic -> + openConversation(topic, conversationId) + }, + onNewConversationClick = { openNewConversation() }, + onNewGroupClick = { openNewConversation() }, + onHideDeletedMessagesToggle = { enabled -> onHideDeletedMessagesToggled(enabled) }, + onToggleLogsClick = { enabled -> onLogsToggled(enabled) }, + onCopyAddressClick = { copyWalletAddress() }, + onDisconnectClick = { disconnectWallet() }, + isLogsEnabled = isLogsActivated(), + hideDeletedMessages = KeyUtil(this).getHideDeletedMessages(), + ) } } - binding.fab.setOnClickListener { - openConversationDetail() - } - - binding.groupFab.setOnClickListener { - openGroupDetail() - } - - binding.logsFab.setOnClickListener { - openLogsViewer() - } - lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { ClientManager.clientState.collect(::ensureClientState) } } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::ensureUiState) - } - } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.stream.collect(::addStreamedItem) - } - } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -122,10 +123,12 @@ class MainActivity : is ClientManager.ClientState.Error -> { retryCreateClientWithBackoff() } + is ClientManager.ClientState.Ready -> { retryJob?.cancel() retryJob = null } + else -> Unit } } @@ -180,97 +183,50 @@ class MainActivity : } override fun onDestroy() { - bottomSheet?.dismiss() - groupBottomSheet?.dismiss() - logsBottomSheet?.dismiss() + // Cancel the retry job to prevent memory leaks + retryJob?.cancel() + retryJob = null super.onDestroy() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.disconnect -> { - disconnectWallet() - true - } - R.id.copy_address -> { - copyWalletAddress() - true - } - R.id.activate_logs -> { - Client.activatePersistentLibXMTPLogWriter( - applicationContext, - FfiLogLevel.DEBUG, - FfiLogRotation.MINUTELY, - 3, - ) - setLogsActivated(true) - Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show() - true - } - R.id.deactivate_logs -> { - Client.deactivatePersistentLibXMTPLogWriter() - setLogsActivated(false) - Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show() - true - } - else -> super.onOptionsItemSelected(item) - } - - override fun onConversationClick(conversation: Conversation) { + private fun openConversation(topic: String, peerAddress: String) { startActivity( ConversationDetailActivity.intent( this, - topic = conversation.topic, - peerAddress = conversation.id, + topic = topic, + peerAddress = peerAddress, ), ) } - override fun onFooterClick(address: String) { - copyWalletAddress() - } - private fun ensureClientState(clientState: ClientManager.ClientState) { + Timber.d("ensureClientState: $clientState") when (clientState) { is ClientManager.ClientState.Ready -> { + Timber.d("ensureClientState: Ready, fetching conversations...") viewModel.fetchConversations() - binding.fab.visibility = View.VISIBLE - binding.groupFab.visibility = View.VISIBLE - binding.logsFab.visibility = View.VISIBLE } - is ClientManager.ClientState.Error -> showError(clientState.message) - is ClientManager.ClientState.Unknown -> Unit - } - } - - private fun addStreamedItem(item: MainViewModel.MainListItem?) { - item?.let { - adapter.addItem(item) - } - } - private fun ensureUiState(uiState: MainViewModel.UiState) { - binding.progress.visibility = View.GONE - when (uiState) { - is MainViewModel.UiState.Loading -> { - if (uiState.listItems.isNullOrEmpty()) { - binding.progress.visibility = View.VISIBLE + is ClientManager.ClientState.Error -> { + Timber.e("ensureClientState: Error - ${clientState.message}") + // If there's no wallet key, clear the account and redirect to sign-in + if (clientState.message.contains("No wallet key found")) { + val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type)) + accounts.forEach { account -> + accountManager.removeAccount(account, null, null, null) + } + showSignIn() } else { - adapter.setData(uiState.listItems) + showError(clientState.message) } } - is MainViewModel.UiState.Success -> { - binding.refresh.isRefreshing = false - adapter.setData(uiState.listItems) + + is ClientManager.ClientState.Unknown -> { + Timber.d("ensureClientState: Unknown") } - is MainViewModel.UiState.Error -> { - binding.refresh.isRefreshing = false - showError(uiState.message) + + is ClientManager.ClientState.Creating -> { + Timber.d("ensureClientState: Creating client...") } } } @@ -287,6 +243,17 @@ class MainActivity : } private fun disconnectWallet() { + // Clear the stored private key and environment before clearing the client + val keyUtil = KeyUtil(this) + // Safely get address only if client is ready + val address = if (ClientManager.clientState.value is ClientManager.ClientState.Ready) { + ClientManager.client.publicIdentity.identifier + } else { + null + } + address?.let { keyUtil.clearPrivateKey(it) } + keyUtil.clearEnvironment() + ClientManager.clearClient() PushNotificationTokenManager.clearXMTPPush() val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type)) @@ -297,33 +264,19 @@ class MainActivity : } private fun copyWalletAddress() { + if (ClientManager.clientState.value !is ClientManager.ClientState.Ready) { + Toast.makeText(this, "Client not ready", Toast.LENGTH_SHORT).show() + return + } val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("inboxId", ClientManager.client.inboxId) + val walletAddress = ClientManager.client.publicIdentity.identifier + val clip = ClipData.newPlainText("wallet_address", walletAddress) clipboard.setPrimaryClip(clip) + Toast.makeText(this, "Wallet address copied", Toast.LENGTH_SHORT).show() } - private fun openConversationDetail() { - bottomSheet = NewConversationBottomSheet.newInstance() - bottomSheet?.show( - supportFragmentManager, - NewConversationBottomSheet.TAG, - ) - } - - private fun openGroupDetail() { - groupBottomSheet = NewGroupBottomSheet.newInstance() - groupBottomSheet?.show( - supportFragmentManager, - NewGroupBottomSheet.TAG, - ) - } - - private fun openLogsViewer() { - logsBottomSheet = LogViewerBottomSheet.newInstance() - logsBottomSheet?.show( - supportFragmentManager, - LogViewerBottomSheet.TAG, - ) + private fun openNewConversation() { + startActivity(NewConversationActivity.intent(this)) } private fun checkAndRequestPermissions() { @@ -350,4 +303,294 @@ class MainActivity : val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_LOGS_ACTIVATED, activated).apply() } + + private fun onLogsToggled(enabled: Boolean) { + if (enabled) { + Client.activatePersistentLibXMTPLogWriter( + applicationContext, + FfiLogLevel.DEBUG, + FfiLogRotation.MINUTELY, + 3, + ) + setLogsActivated(true) + Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show() + } else { + Client.deactivatePersistentLibXMTPLogWriter() + setLogsActivated(false) + Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show() + } + } + + private fun onHideDeletedMessagesToggled(enabled: Boolean) { + val keyUtil = KeyUtil(this) + keyUtil.setHideDeletedMessages(enabled) + val message = if (enabled) "Deleted messages will be hidden" else "Deleted messages will be shown" + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } +} + +@Composable +private fun MainActivityContent( + viewModel: MainViewModel, + onConversationClick: (String, String) -> Unit, + onNewConversationClick: () -> Unit, + onNewGroupClick: () -> Unit, + onHideDeletedMessagesToggle: (Boolean) -> Unit, + onToggleLogsClick: (Boolean) -> Unit, + onCopyAddressClick: () -> Unit, + onDisconnectClick: () -> Unit, + isLogsEnabled: Boolean, + hideDeletedMessages: Boolean, +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + val clientState by ClientManager.clientState.collectAsState() + + // Sheet states + var showWalletInfoSheet by remember { mutableStateOf(false) } + var showLogViewerSheet by remember { mutableStateOf(false) } + + // Track log and hide deleted states for UI updates + var logsEnabled by remember { mutableStateOf(isLogsEnabled) } + var hideDeleted by remember { mutableStateOf(hideDeletedMessages) } + + // Convert ViewModel state to UI state + val conversations = remember(uiState, clientState) { + val items = when (uiState) { + is MainViewModel.UiState.Success -> (uiState as MainViewModel.UiState.Success).listItems + is MainViewModel.UiState.Loading -> (uiState as MainViewModel.UiState.Loading).listItems ?: emptyList() + is MainViewModel.UiState.Error -> emptyList() + } + + items.filterIsInstance().map { item -> + val isGroup = item.conversation.type == Conversation.Type.GROUP + val displayName = when (item.conversation.type) { + Conversation.Type.GROUP -> item.displayName + Conversation.Type.DM -> item.displayName.truncatedAddress() + } + + // Format message time + val formattedTime = item.mostRecentMessage?.let { message -> + formatMessageTime(message.sentAtNs / 1_000_000) + } ?: "" + + // Get message preview + val myInboxId = if (clientState is ClientManager.ClientState.Ready) { + ClientManager.client.inboxId + } else "" + + val messageBody = when (val content = item.mostRecentMessage?.content()) { + is String -> content + is Reply -> when (val replyContent = content.content) { + is String -> replyContent + else -> "Message" + } + is Attachment -> if (content.mimeType.startsWith("image/")) { + if (content.mimeType == "image/gif") "GIF" else "Photo" + } else "Attachment" + is GroupUpdated -> { + val added = content.addedInboxesList?.size ?: 0 + val removed = content.removedInboxesList?.size ?: 0 + when { + added > 0 && removed > 0 -> "$added added, $removed removed" + added > 0 -> "$added member${if (added > 1) "s" else ""} added" + removed > 0 -> "$removed member${if (removed > 1) "s" else ""} removed" + else -> "Group updated" + } + } + is DeletedMessage -> "Message deleted" + else -> "Message" + } + + val isMe = item.mostRecentMessage?.senderInboxId == myInboxId + val lastMessage = if (messageBody.isNotBlank()) { + if (isMe) "You: $messageBody" else messageBody + } else "" + + ConversationItem( + id = item.conversation.topic, + name = displayName, + lastMessage = lastMessage, + timestamp = formattedTime, + isGroup = isGroup, + memberCount = null, // Would require async call to get member count + unreadCount = 0, + ) + } + } + + val isLoading = uiState is MainViewModel.UiState.Loading && + (uiState as MainViewModel.UiState.Loading).listItems.isNullOrEmpty() + + // Get wallet info from client state + val walletAddress = remember(clientState) { + if (clientState is ClientManager.ClientState.Ready) { + ClientManager.client.publicIdentity.identifier + } else { + "" + } + } + + val environment = remember(clientState) { + if (clientState is ClientManager.ClientState.Ready) { + ClientManager.client.environment.name + } else { + "" + } + } + + val inboxId = remember(clientState) { + if (clientState is ClientManager.ClientState.Ready) { + ClientManager.client.inboxId + } else { + "" + } + } + + val installationId = remember(clientState) { + if (clientState is ClientManager.ClientState.Ready) { + ClientManager.client.installationId + } else { + "" + } + } + + val drawerState = DrawerState( + walletAddress = walletAddress, + environment = environment, + isLogsEnabled = logsEnabled, + hideDeletedMessages = hideDeleted, + ) + + // Find topic for conversation navigation (topic is the conversation id) + val conversationTopics = remember(uiState) { + val items = when (uiState) { + is MainViewModel.UiState.Success -> (uiState as MainViewModel.UiState.Success).listItems + is MainViewModel.UiState.Loading -> (uiState as MainViewModel.UiState.Loading).listItems ?: emptyList() + else -> emptyList() + } + items.filterIsInstance() + .associate { it.conversation.topic to it.conversation.topic } + } + + MainScreen( + conversations = conversations, + drawerState = drawerState, + isLoading = isLoading, + onConversationClick = { conversationId -> + val topic = conversationTopics[conversationId] ?: "" + onConversationClick(conversationId, topic) + }, + onNewConversationClick = onNewConversationClick, + onSearchClick = { /* TODO: Implement search */ }, + onWalletInfoClick = { showWalletInfoSheet = true }, + onNewGroupClick = onNewGroupClick, + onHideDeletedMessagesToggle = { enabled -> + hideDeleted = enabled + onHideDeletedMessagesToggle(enabled) + }, + onViewLogsClick = { showLogViewerSheet = true }, + onToggleLogsClick = { enabled -> + logsEnabled = enabled + onToggleLogsClick(enabled) + }, + onCopyAddressClick = onCopyAddressClick, + onDisconnectClick = onDisconnectClick, + ) + + // Wallet Info Sheet + if (showWalletInfoSheet) { + val walletInfo = WalletInfo( + walletAddress = walletAddress, + inboxId = inboxId, + installationId = installationId, + environment = environment, + libXmtpVersion = if (clientState is ClientManager.ClientState.Ready) ClientManager.client.libXMTPVersion else "", + ) + + WalletInfoSheet( + walletInfo = walletInfo, + isLogsEnabled = logsEnabled, + onDismiss = { showWalletInfoSheet = false }, + onCopyValue = { label, value -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, value) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "$label copied", Toast.LENGTH_SHORT).show() + }, + onLogsToggled = { enabled -> + logsEnabled = enabled + onToggleLogsClick(enabled) + }, + onDisconnect = { + showWalletInfoSheet = false + onDisconnectClick() + }, + ) + } + + // Log Viewer Sheet + if (showLogViewerSheet) { + val logDir = File(context.filesDir, "xmtp_logs") + val logFiles = if (logDir.exists()) { + logDir.listFiles() + ?.filter { it.isFile && it.name.endsWith(".log") } + ?.sortedByDescending { it.lastModified() } + ?.map { file -> + LogFileInfo( + file = file, + name = file.name, + size = formatFileSize(file.length()), + ) + } ?: emptyList() + } else { + emptyList() + } + + LogViewerSheet( + logFiles = logFiles, + onDismiss = { showLogViewerSheet = false }, + onShareFile = { file -> + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(shareIntent, "Share Log File")) + }, + ) + } +} + +private fun formatMessageTime(timestampMs: Long): String { + val messageDate = Date(timestampMs) + val now = Calendar.getInstance() + val messageCalendar = Calendar.getInstance().apply { time = messageDate } + + return when { + // Today - show time + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate) + } + // Yesterday + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> { + "Yesterday" + } + // Within the last week - show day name + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> { + SimpleDateFormat("EEE", Locale.getDefault()).format(messageDate) + } + // Older - show date + else -> { + SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate) + } + } } diff --git a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt index 640ba9511..52abaec52 100644 --- a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt @@ -1,7 +1,6 @@ package org.xmtp.android.example import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -12,17 +11,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.xmtp.android.example.extension.flowWhileShared -import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager import org.xmtp.android.library.Conversation import org.xmtp.android.library.Topic import org.xmtp.android.library.libxmtp.DecodedMessage import org.xmtp.android.library.push.Service +import timber.log.Timber class MainViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState.Loading(null)) @@ -44,9 +45,12 @@ class MainViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { val listItems = mutableListOf() try { + Timber.d("fetchConversations: starting, clientState=${ClientManager.clientState.value}") val conversations = ClientManager.client.conversations // Ensure we fetch the latest conversations from the network before listing + Timber.d("fetchConversations: syncing conversations...") conversations.sync() + Timber.d("fetchConversations: sync complete") val subscriptions = conversations .allPushTopics() @@ -82,53 +86,98 @@ class MainViewModel : ViewModel() { subscriptions.add(welcomeTopic) PushNotificationTokenManager.xmtpPush.subscribeWithMetadata(subscriptions) + val conversationList = conversations.list() + Timber.d("fetchConversations: found ${conversationList.size} conversations") listItems.addAll( - conversations.list().map { conversation -> + conversationList.map { conversation -> val lastMessage = fetchMostRecentMessage(conversation) + val (displayName, peerAddress) = getConversationDisplayInfo(conversation) MainListItem.ConversationItem( id = conversation.topic, - conversation, - lastMessage, + conversation = conversation, + mostRecentMessage = lastMessage, + displayName = displayName, + peerAddress = peerAddress, ) }, ) - listItems.add( - MainListItem.Footer( - id = "footer", - ClientManager.client.inboxId, - ClientManager.client.environment.name, - ), - ) + Timber.d("fetchConversations: success, total items=${listItems.size}") _uiState.value = UiState.Success(listItems) } catch (e: Exception) { + Timber.e(e, "fetchConversations: error") _uiState.value = UiState.Error(e.localizedMessage.orEmpty()) } } } - @WorkerThread - private fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? = - runBlocking { conversation.lastMessage() } + private suspend fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? = conversation.lastMessage() + + private suspend fun getConversationDisplayInfo(conversation: Conversation): Pair = + when (conversation) { + is Conversation.Group -> { + val groupName = conversation.group.name() + val displayName = if (groupName.isNotBlank()) groupName else conversation.id + Pair(displayName, null) + } + is Conversation.Dm -> { + val peerInboxId = conversation.dm.peerInboxId + val members = conversation.dm.members() + val peerMember = members.find { it.inboxId == peerInboxId } + val peerAddress = peerMember?.identities?.firstOrNull()?.identifier + val displayName = peerAddress ?: conversation.id + Pair(displayName, peerAddress) + } + } + + // Stream for new conversations - reacts to client state changes @OptIn(ExperimentalCoroutinesApi::class) val stream: StateFlow = - stateFlow(viewModelScope, null) { subscriptionCount -> - if (ClientManager.clientState.value is ClientManager.ClientState.Ready) { + ClientManager.clientState + .filterIsInstance() + .flatMapLatest { ClientManager.client.conversations .stream() - .flowWhileShared( - subscriptionCount, - SharingStarted.WhileSubscribed(1000L), - ).flowOn(Dispatchers.IO) .distinctUntilChanged() - .mapLatest { conversation -> + .mapLatest { conversation -> val lastMessage = fetchMostRecentMessage(conversation) - MainListItem.ConversationItem(conversation.topic, conversation, lastMessage) - }.catch { emptyFlow() } - } else { - emptyFlow() - } - } + val (displayName, peerAddress) = getConversationDisplayInfo(conversation) + MainListItem.ConversationItem( + id = conversation.topic, + conversation = conversation, + mostRecentMessage = lastMessage, + displayName = displayName, + peerAddress = peerAddress, + ) + }.catch { e -> + Timber.e(e, "Error in conversation stream") + emit(null) + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + + // Stream for message updates - triggers when any conversation receives a new message + // Uses flatMapLatest to react to client state changes + @OptIn(ExperimentalCoroutinesApi::class) + val messageStream: StateFlow = + ClientManager.clientState + .filterIsInstance() + .flatMapLatest { + ClientManager.client.conversations + .streamAllMessages() + .map { message -> + MessageUpdate(message.topic, message) + }.catch { e -> + Timber.e(e, "Error in message stream") + emit(null) + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + + data class MessageUpdate( + val topic: String, + val message: DecodedMessage, + ) sealed class UiState { data class Loading( @@ -150,19 +199,14 @@ class MainViewModel : ViewModel() { ) { companion object { const val ITEM_TYPE_CONVERSATION = 1 - const val ITEM_TYPE_FOOTER = 2 } data class ConversationItem( override val id: String, val conversation: Conversation, val mostRecentMessage: DecodedMessage?, + val displayName: String, + val peerAddress: String? = null, ) : MainListItem(id, ITEM_TYPE_CONVERSATION) - - data class Footer( - override val id: String, - val address: String, - val environment: String, - ) : MainListItem(id, ITEM_TYPE_FOOTER) } } diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt index a4bac181a..eabdb5154 100644 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt @@ -3,28 +3,48 @@ package org.xmtp.android.example.connect import android.accounts.Account import android.accounts.AccountManager import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.view.View -import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.isVisible -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.launch +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import org.xmtp.android.example.MainActivity import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.ActivityConnectWalletBinding +import org.xmtp.android.example.ui.screens.ConnectWalletScreen +import org.xmtp.android.example.ui.theme.XMTPTheme -class ConnectWalletActivity : AppCompatActivity() { - private lateinit var binding: ActivityConnectWalletBinding +class ConnectWalletActivity : ComponentActivity() { + private val viewModel: ConnectWalletViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() - binding = ActivityConnectWalletBinding.inflate(layoutInflater) - setContentView(binding.root) + setContent { + XMTPTheme { + val uiState by viewModel.uiState.collectAsState() + + ConnectWalletScreen( + uiState = uiState, + onGenerateWallet = { environment, logLevel -> + viewModel.generateWallet(environment, logLevel) + }, + onConnectSuccess = { address -> + signIn(address) + }, + ) + } + } + } + + private fun signIn(address: String) { + val accountManager = AccountManager.get(this) + Account(address, resources.getString(R.string.account_type)).also { account -> + accountManager.addAccountExplicitly(account, address, null) + } + startActivity(Intent(this, MainActivity::class.java)) + finish() } } diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt deleted file mode 100644 index 2c0e360b1..000000000 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.xmtp.android.example.connect - -import android.accounts.Account -import android.accounts.AccountManager -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import com.walletconnect.wcmodal.client.WalletConnectModal -import com.walletconnect.wcmodal.ui.openWalletConnectModal -import kotlinx.coroutines.launch -import org.xmtp.android.example.MainActivity -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.FragmentConnectWalletBinding -import timber.log.Timber - -class ConnectWalletFragment : Fragment() { - private val viewModel: ConnectWalletViewModel by viewModels() - - private var _binding: FragmentConnectWalletBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentConnectWalletBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::ensureUiState) - } - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.showWalletState.collect(::showWalletState) - } - } - - binding.generateButton.setOnClickListener { - viewModel.generateWallet() - } - } - - private fun ensureUiState(uiState: ConnectWalletViewModel.ConnectUiState) { - when (uiState) { - is ConnectWalletViewModel.ConnectUiState.Error -> showError(uiState.message) - ConnectWalletViewModel.ConnectUiState.Loading -> showLoading() - is ConnectWalletViewModel.ConnectUiState.Success -> signIn(uiState.address) - - ConnectWalletViewModel.ConnectUiState.Unknown -> Unit - } - } - - private fun showWalletState(walletState: ConnectWalletViewModel.ShowWalletForSigningState) { - if (walletState.showWallet) { - try { - val intent = Intent(Intent.ACTION_VIEW, walletState.uri) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - requireActivity().startActivity(intent) - viewModel.clearShowWalletState() - } catch (e: Exception) { - Timber.tag(FRAGMENT_LOG_TAG).e("Activity not found: $e") - } - } - } - - private fun signIn(address: String) { - val accountManager = AccountManager.get(requireContext()) - Account(address, resources.getString(R.string.account_type)).also { account -> - accountManager.addAccountExplicitly(account, address, null) - } - requireActivity().startActivity(Intent(requireActivity(), MainActivity::class.java)) - requireActivity().finish() - } - - private fun showError(message: String) { - binding.progress.visibility = View.GONE - binding.generateButton.visibility = View.VISIBLE - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - } - - private fun showLoading() { - binding.progress.visibility = View.VISIBLE - binding.generateButton.visibility = View.GONE - binding.connectButton.visibility = View.GONE - binding.connectError.visibility = View.GONE - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - companion object { - private const val WC_URI_SCHEME = "wc://wc?uri=" - private const val FRAGMENT_LOG_TAG = "ConnectWalletFragment" - } -} diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt index 35c88e24e..5f89b9ba5 100644 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt @@ -16,10 +16,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.xmtp.android.example.ClientManager +import org.xmtp.android.example.utils.KeyUtil import org.xmtp.android.library.Client -import org.xmtp.android.library.XMTPException -import org.xmtp.android.library.codecs.GroupUpdatedCodec +import org.xmtp.android.library.XMTPEnvironment import org.xmtp.android.library.messages.PrivateKeyBuilder +import uniffi.xmtpv3.FfiLogLevel +import uniffi.xmtpv3.FfiLogRotation class ConnectWalletViewModel( application: Application, @@ -32,23 +34,57 @@ class ConnectWalletViewModel( val uiState: StateFlow = _uiState @UiThread - fun generateWallet() { + fun generateWallet( + environment: XMTPEnvironment, + logLevel: FfiLogLevel?, + ) { viewModelScope.launch(Dispatchers.IO) { _uiState.value = ConnectUiState.Loading try { + // Store the selected environment and log level + ClientManager.selectedEnvironment = environment + ClientManager.selectedLogLevel = logLevel + + // Activate logging if a log level is selected + if (logLevel != null) { + Client.activatePersistentLibXMTPLogWriter( + getApplication(), + logLevel, + FfiLogRotation.MINUTELY, + 3, + ) + } + val wallet = PrivateKeyBuilder() + val address = wallet.publicIdentity.identifier + + // Store the private key and environment for later use on app restart + val keyUtil = KeyUtil(getApplication()) + val privateKeyBytes = + wallet + .getPrivateKey() + .secp256K1.bytes + .toByteArray() + keyUtil.storePrivateKey(address, privateKeyBytes) + keyUtil.storeEnvironment(environment.name) + val client = Client.create( wallet, - ClientManager.clientOptions(getApplication(), wallet.publicIdentity.identifier), + ClientManager.clientOptions( + getApplication(), + address, + environment, + ), ) - Client.register(codec = GroupUpdatedCodec()) + // Store the client in ClientManager so it can be used by MainActivity + ClientManager.setClient(client) _uiState.value = ConnectUiState.Success( - wallet.publicIdentity.identifier, + address, ) - } catch (e: XMTPException) { - _uiState.value = ConnectUiState.Error(e.message.orEmpty()) + } catch (e: Exception) { + _uiState.value = ConnectUiState.Error(e.message ?: "Unknown error occurred") } } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt new file mode 100644 index 000000000..292a63b68 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt @@ -0,0 +1,185 @@ +package org.xmtp.android.example.conversation + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.format.Formatter +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.xmtp.android.example.ui.screens.AttachmentPreviewItem +import org.xmtp.android.example.ui.screens.AttachmentPreviewScreen +import org.xmtp.android.example.ui.theme.XMTPTheme + +class AttachmentPreviewActivity : ComponentActivity() { + companion object { + private const val EXTRA_ATTACHMENT_URIS = "attachment_uris" + const val RESULT_ATTACHMENTS = "result_attachments" + const val RESULT_CAPTIONS = "result_captions" + + fun intent( + context: Context, + uris: List, + ): Intent = + Intent(context, AttachmentPreviewActivity::class.java).apply { + putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris)) + // Grant read permission for URIs so they can be accessed in the preview activity + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + private val attachmentUris = mutableListOf() + private val captions = mutableMapOf() + private val previewBitmaps = mutableMapOf() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Get attachment URIs from intent (API 33+ compatible) + val uris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS) + } + + if (uris.isNullOrEmpty()) { + finish() + return + } + + attachmentUris.addAll(uris) + + setContent { + XMTPTheme { + var currentCaption by remember { mutableStateOf("") } + var currentIndex by remember { mutableIntStateOf(0) } + + // Build attachment preview items + val attachments = remember { + mutableStateListOf().apply { + attachmentUris.forEachIndexed { index, uri -> + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val isImage = mimeType.startsWith("image/") + val filename = getFilename(uri) + val fileSize = Formatter.formatFileSize( + this@AttachmentPreviewActivity, + getFileSize(uri), + ) + + // Load bitmap for images + val bitmap = if (isImage) { + loadBitmap(uri)?.also { previewBitmaps[index] = it } + } else { + null + } + + add( + AttachmentPreviewItem( + uri = uri, + filename = filename, + fileSize = fileSize, + mimeType = mimeType, + thumbnail = bitmap, + isImage = isImage, + ), + ) + } + } + } + + AttachmentPreviewScreen( + attachments = attachments, + caption = currentCaption, + selectedIndex = currentIndex, + onCaptionChange = { caption -> + currentCaption = caption + captions[currentIndex] = caption + }, + onSelectedIndexChange = { newIndex -> + // Save current caption before switching + captions[currentIndex] = currentCaption + currentIndex = newIndex + // Load caption for new index + currentCaption = captions[newIndex] ?: "" + }, + onClose = { + setResult(Activity.RESULT_CANCELED) + finish() + }, + onSend = { + // Save current caption before sending + captions[currentIndex] = currentCaption + + val resultIntent = Intent().apply { + putParcelableArrayListExtra(RESULT_ATTACHMENTS, ArrayList(attachmentUris)) + putStringArrayListExtra( + RESULT_CAPTIONS, + ArrayList(attachmentUris.indices.map { idx -> captions[idx] ?: "" }), + ) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + }, + ) + } + } + } + + private fun loadBitmap(uri: Uri): Bitmap? { + return try { + contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + Toast.makeText(this, "Failed to load image", Toast.LENGTH_SHORT).show() + null + } + } + + private fun getFilename(uri: Uri): String { + var filename = "attachment" + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + filename = cursor.getString(nameIndex) ?: "attachment" + } + } + } + return filename + } + + private fun getFileSize(uri: Uri): Long { + var size = 0L + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (sizeIndex >= 0) { + size = cursor.getLong(sizeIndex) + } + } + } + return size + } + + override fun onDestroy() { + super.onDestroy() + // Recycle bitmaps to prevent memory leaks + previewBitmaps.values.forEach { it.recycle() } + previewBitmaps.clear() + } +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt index a442f8355..3a260a813 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt @@ -1,37 +1,116 @@ package org.xmtp.android.example.conversation -import android.R.id.home -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.widget.addTextChangedListener +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Gif +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.ActivityConversationDetailBinding +import kotlinx.coroutines.withContext +import org.xmtp.android.example.ClientManager import org.xmtp.android.example.extension.truncatedAddress -import org.xmtp.android.example.message.MessageAdapter - -class ConversationDetailActivity : AppCompatActivity() { - private lateinit var binding: ActivityConversationDetailBinding - private lateinit var adapter: MessageAdapter +import org.xmtp.android.example.ui.components.EmojiPicker +import org.xmtp.android.example.ui.components.MessageBubble +import org.xmtp.android.example.ui.components.MessageComposer +import org.xmtp.android.example.ui.components.ReplyPreviewBar +import org.xmtp.android.example.ui.components.ReplyPreviewData +import org.xmtp.android.example.ui.components.SystemMessage +import org.xmtp.android.example.ui.dialogs.DeleteConfirmationDialog +import org.xmtp.android.example.ui.sheets.MessageOptionsSheet +import org.xmtp.android.example.ui.sheets.MessageOptionsState +import org.xmtp.android.example.ui.theme.XMTPTheme +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import org.xmtp.android.library.libxmtp.Reply +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs +class ConversationDetailActivity : ComponentActivity() { private val viewModel: ConversationDetailViewModel by viewModels() - private val peerAddress - get() = intent.extras?.getString(EXTRA_PEER_ADDRESS) + private val avatarColors = listOf( + Color(0xFFFC4F37), + Color(0xFF5856D6), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFF007AFF), + Color(0xFFAF52DE), + Color(0xFF00C7BE), + Color(0xFFFF2D55), + ) companion object { const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC" @@ -50,138 +129,905 @@ class ConversationDetailActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() + viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC)) - binding = ActivityConversationDetailBinding.inflate(layoutInflater) - setContentView(binding.root) - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.subtitle = - if (peerAddress != null && peerAddress!!.contains(",")) { - val addresses = peerAddress?.split(",")?.toMutableList() - addresses?.joinToString(" & ") { - it.truncatedAddress() - } - } else { - peerAddress?.truncatedAddress() - } + // Load and apply user preferences for hide deleted messages + val keyUtil = org.xmtp.android.example.utils.KeyUtil(this) + viewModel.setHideDeletedMessages(keyUtil.getHideDeletedMessages()) - adapter = MessageAdapter() - binding.list.layoutManager = - LinearLayoutManager(this, RecyclerView.VERTICAL, true) - binding.list.adapter = adapter + viewModel.fetchMessages() + // Observe streamed messages lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::ensureUiState) + viewModel.streamMessages.collect { result -> + when (result) { + is ConversationDetailViewModel.StreamedMessageResult.RefreshNeeded -> { + viewModel.fetchMessages() + } + is ConversationDetailViewModel.StreamedMessageResult.NewMessage -> { + viewModel.fetchMessages() + } + null -> {} + } + } } } - binding.messageEditText.requestFocus() - binding.messageEditText.addTextChangedListener { - val sendEnabled = !binding.messageEditText.text.isNullOrBlank() - binding.sendButton.isEnabled = sendEnabled + setContent { + XMTPTheme { + ConversationDetailScreen( + viewModel = viewModel, + peerAddress = intent.extras?.getString(EXTRA_PEER_ADDRESS) ?: "", + conversationTopic = intent.extras?.getString(EXTRA_CONVERSATION_TOPIC) ?: "", + avatarColors = avatarColors, + onBackClick = { finish() }, + onNavigateToGroupManagement = { topic -> + startActivity(GroupManagementActivity.intent(this, topic)) + }, + onNavigateToUserProfile = { address, inboxId -> + startActivity(UserProfileActivity.intent(this, address, inboxId)) + }, + onNavigateToAttachmentPreview = { uris -> + startActivity(AttachmentPreviewActivity.intent(this, uris)) + }, + ) + } } + } + + override fun onResume() { + super.onResume() + viewModel.fetchMessages() + } +} + +// Data class for message display +private data class MessageDisplayItem( + val id: String, + val message: String, + val timestamp: String, + val isMe: Boolean, + val senderName: String?, + val senderColor: Color?, + val isSystemMessage: Boolean, + val reactions: List>?, + val replyPreview: String?, + val replyAuthor: String?, + val isDeleted: Boolean, + val originalMessage: DecodedMessageV2?, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationDetailScreen( + viewModel: ConversationDetailViewModel, + peerAddress: String, + conversationTopic: String, + avatarColors: List, + onBackClick: () -> Unit, + onNavigateToGroupManagement: (String) -> Unit, + onNavigateToUserProfile: (String, String?) -> Unit, + onNavigateToAttachmentPreview: (List) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsState() + val replyToMessage by viewModel.replyToMessage.collectAsState() + + // Message composer state + var messageText by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + + // Conversation info state + var conversationType by remember { mutableStateOf(null) } + var headerTitle by remember { mutableStateOf(peerAddress.truncatedAddress()) } + var headerSubtitle by remember { mutableStateOf("Loading...") } + var peerWalletAddress by remember { mutableStateOf(null) } + var peerInboxId by remember { mutableStateOf(null) } + var isSuperAdmin by remember { mutableStateOf(false) } + + // Sheet states + var showAttachmentPicker by remember { mutableStateOf(false) } + var showMessageOptions by remember { mutableStateOf(false) } + var showEmojiPicker by remember { mutableStateOf(false) } + var selectedMessage by remember { mutableStateOf(null) } + var showDeleteConfirmation by remember { mutableStateOf(false) } + + // Camera image URI state + var cameraImageUri by remember { mutableStateOf(null) } + + // Pending attachments to send after preview + var pendingAttachmentUris by remember { mutableStateOf>(emptyList()) } + var pendingCaptions by remember { mutableStateOf>(emptyList()) } + + // Attachment preview launcher - handles result when user confirms sending + val attachmentPreviewLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + timber.log.Timber.d("AttachmentPreviewLauncher: resultCode=${result.resultCode}, data=${result.data}") + if (result.resultCode == android.app.Activity.RESULT_OK) { + val data = result.data + val uris = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + data?.getParcelableArrayListExtra(AttachmentPreviewActivity.RESULT_ATTACHMENTS, Uri::class.java) + } else { + @Suppress("DEPRECATION") + data?.getParcelableArrayListExtra(AttachmentPreviewActivity.RESULT_ATTACHMENTS) + } + val captions = data?.getStringArrayListExtra(AttachmentPreviewActivity.RESULT_CAPTIONS) + timber.log.Timber.d("AttachmentPreviewLauncher: uris=$uris, captions=$captions") + + if (!uris.isNullOrEmpty()) { + // Send each attachment + isSending = true + scope.launch { + try { + uris.forEachIndexed { index, uri -> + val caption = captions?.getOrNull(index) ?: "" + + // Read file info from URI + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" + var filename = "attachment" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + filename = cursor.getString(nameIndex) ?: "attachment" + } + } + } + + // Read file data + val fileData = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } catch (e: Exception) { + timber.log.Timber.e(e, "Failed to read file: $uri") + null + } + } + + if (fileData != null) { + timber.log.Timber.d("Sending attachment: $filename, type: $mimeType, size: ${fileData.size}") - binding.sendButton.setOnClickListener { - val flow = viewModel.sendMessage(binding.messageEditText.text.toString()) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - flow.collect(::ensureSendState) + // Send caption first if present + if (caption.isNotBlank()) { + viewModel.sendMessage(caption).collect { /* ignore */ } + } + + // Send attachment + val sendResult = viewModel.sendAttachment(filename, mimeType, fileData) + when (sendResult) { + is ConversationDetailViewModel.SendAttachmentState.Success -> { + timber.log.Timber.d("Attachment sent successfully: $filename") + } + is ConversationDetailViewModel.SendAttachmentState.Error -> { + timber.log.Timber.e("Failed to send attachment: ${sendResult.message}") + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed: ${sendResult.message}", Toast.LENGTH_LONG).show() + } + } + else -> {} + } + } else { + timber.log.Timber.e("Failed to read file data for: $uri") + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed to read file", Toast.LENGTH_SHORT).show() + } + } + } + viewModel.fetchMessages() + } finally { + isSending = false + } } } } + } - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.streamMessages.collect(::addStreamedItem) + // Activity result launchers + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + // Take persistable permission so URIs remain readable after preview returns + uris.forEach { uri -> + try { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: SecurityException) { + // Some URIs may not support persistable permissions + } } + attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, uris)) } + } - binding.refresh.setOnRefreshListener { - viewModel.fetchMessages() + val cameraLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicture() + ) { success -> + if (success && cameraImageUri != null) { + attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, listOf(cameraImageUri!!))) } + } - viewModel.fetchMessages() + val fileLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + if (uris.isNotEmpty()) { + // Take persistable permission so URIs remain readable after preview returns + uris.forEach { uri -> + try { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: SecurityException) { + // Some URIs may not support persistable permissions + } + } + attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, uris)) + } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true + // Load conversation info + LaunchedEffect(conversationTopic) { + withContext(Dispatchers.IO) { + try { + val conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic) + conversation?.let { conv -> + conversationType = conv.type + + when (conv) { + is Conversation.Group -> { + val groupName = conv.group.name() + val members = conv.group.members() + val memberCount = members.size + isSuperAdmin = conv.group.isSuperAdmin(ClientManager.client.inboxId) + + withContext(Dispatchers.Main) { + headerTitle = if (groupName.isNullOrBlank()) { + conv.id.truncatedAddress() + } else { + groupName + } + headerSubtitle = "$memberCount members" + } + } + is Conversation.Dm -> { + val members = conv.dm.members() + val peerMember = members.find { it.inboxId != ClientManager.client.inboxId } + peerMember?.let { member -> + peerInboxId = member.inboxId + peerWalletAddress = member.identities.firstOrNull()?.identifier + + withContext(Dispatchers.Main) { + val displayAddress = peerWalletAddress ?: conv.id + headerTitle = displayAddress.truncatedAddress() + headerSubtitle = "Direct message" + } + } + } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + headerSubtitle = "Direct message" + } + } + } } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - home -> { - finish() - true + // Convert ViewModel state to UI messages + val messages = remember(uiState) { + val items = when (uiState) { + is ConversationDetailViewModel.UiState.Success -> + (uiState as ConversationDetailViewModel.UiState.Success).listItems + is ConversationDetailViewModel.UiState.Loading -> + (uiState as ConversationDetailViewModel.UiState.Loading).listItems ?: emptyList() + is ConversationDetailViewModel.UiState.Error -> emptyList() + } + + items.map { item -> + when (item) { + is ConversationDetailViewModel.MessageListItem.SentMessage -> { + convertToDisplayItem(item.message, isFromMe = true, avatarColors = avatarColors) + } + is ConversationDetailViewModel.MessageListItem.ReceivedMessage -> { + convertToDisplayItem(item.message, isFromMe = false, avatarColors = avatarColors) + } + is ConversationDetailViewModel.MessageListItem.SystemMessage -> { + MessageDisplayItem( + id = item.id, + message = item.text, + timestamp = formatMessageTime(item.message.sentAtNs / 1_000_000), + isMe = false, + senderName = null, + senderColor = null, + isSystemMessage = true, + reactions = null, + replyPreview = null, + replyAuthor = null, + isDeleted = false, + originalMessage = item.message, + ) + } } + } + } + + val isLoading = uiState is ConversationDetailViewModel.UiState.Loading && + (uiState as ConversationDetailViewModel.UiState.Loading).listItems.isNullOrEmpty() + + val listState = rememberLazyListState() - R.id.copy_address -> { - copyWalletAddress() - true + // Scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(0) + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + topBar = { + ConversationHeader( + title = headerTitle, + subtitle = headerSubtitle, + avatarText = headerTitle.removePrefix("0x").take(2).uppercase(), + avatarColor = avatarColors[abs(headerTitle.hashCode()) % avatarColors.size], + onBackClick = onBackClick, + onHeaderClick = { + when (conversationType) { + Conversation.Type.GROUP -> onNavigateToGroupManagement(conversationTopic) + Conversation.Type.DM -> peerWalletAddress?.let { address -> + onNavigateToUserProfile(address, peerInboxId) + } + null -> {} + } + }, + ) + }, + bottomBar = { + Column { + // Reply preview + replyToMessage?.let { message -> + val content = message.content() + val replyText = when (content) { + is String -> content + is Reply -> (content.content as? String) ?: "Message" + is Attachment -> if (content.mimeType.startsWith("image/")) "Photo" else "Attachment" + else -> "Message" + } + ReplyPreviewBar( + replyData = ReplyPreviewData( + authorName = message.senderInboxId.take(8) + "...", + messageText = replyText, + ), + onClose = { viewModel.clearReply() }, + ) + } + + MessageComposer( + value = messageText, + onValueChange = { messageText = it }, + onSendClick = { + if (messageText.isNotBlank()) { + isSending = true + val textToSend = messageText + messageText = "" + scope.launch { + viewModel.sendMessage(textToSend).collect { state -> + when (state) { + is ConversationDetailViewModel.SendMessageState.Success -> { + isSending = false + viewModel.fetchMessages() + } + is ConversationDetailViewModel.SendMessageState.Error -> { + isSending = false + Toast.makeText(context, state.message, Toast.LENGTH_SHORT).show() + } + else -> {} + } + } + } + } + }, + isSending = isSending, + onAttachmentClick = { showAttachmentPicker = true }, + onEmojiClick = { showEmojiPicker = true }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + reverseLayout = true, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(messages, key = { it.id }) { displayItem -> + if (displayItem.isSystemMessage) { + SystemMessage(text = displayItem.message) + } else { + MessageBubble( + message = displayItem.message, + timestamp = displayItem.timestamp, + isMe = displayItem.isMe, + senderName = displayItem.senderName, + senderColor = displayItem.senderColor, + reactions = displayItem.reactions, + replyPreview = displayItem.replyPreview, + replyAuthor = displayItem.replyAuthor, + isDeleted = displayItem.isDeleted, + onLongClick = { + displayItem.originalMessage?.let { + selectedMessage = it + showMessageOptions = true + } + }, + onReplyClick = displayItem.replyPreview?.let { + { /* Could scroll to original message */ } + }, + ) + } + } + } } + } + } - else -> super.onOptionsItemSelected(item) + // Attachment Picker Sheet + if (showAttachmentPicker) { + ModalBottomSheet( + onDismissRequest = { showAttachmentPicker = false }, + sheetState = rememberModalBottomSheetState(), + ) { + AttachmentPickerContent( + onCameraClick = { + showAttachmentPicker = false + val photoFile = File(context.cacheDir, "camera_${System.currentTimeMillis()}.jpg") + cameraImageUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile, + ) + cameraLauncher.launch(cameraImageUri) + }, + onGalleryClick = { + showAttachmentPicker = false + galleryLauncher.launch("image/*") + }, + onFileClick = { + showAttachmentPicker = false + fileLauncher.launch(arrayOf("*/*")) + }, + onGifClick = { + showAttachmentPicker = false + galleryLauncher.launch("image/gif") + }, + ) } + } - private fun copyWalletAddress() { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("peer_address", peerAddress) - clipboard.setPrimaryClip(clip) + // Emoji Picker Sheet + if (showEmojiPicker) { + ModalBottomSheet( + onDismissRequest = { showEmojiPicker = false }, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Choose emoji", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 16.dp), + ) + EmojiPicker( + onEmojiSelected = { emoji -> + messageText += emoji + showEmojiPicker = false + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } } - private fun ensureUiState(uiState: ConversationDetailViewModel.UiState) { - binding.progress.visibility = View.GONE - when (uiState) { - is ConversationDetailViewModel.UiState.Loading -> { - if (uiState.listItems.isNullOrEmpty()) { - binding.progress.visibility = View.VISIBLE - } else { - adapter.setData(uiState.listItems) + // Message Options Sheet + if (showMessageOptions && selectedMessage != null) { + val message = selectedMessage!! + val isFromMe = ClientManager.client.inboxId == message.senderInboxId + val content = message.content() + val canEdit = isFromMe && (content is String || content is Reply) + val canDelete = isFromMe || (conversationType == Conversation.Type.GROUP && isSuperAdmin) + + // Find user's existing reaction + var myExistingReaction: String? = null + if (message.hasReactions) { + val sortedReactions = message.reactions.sortedBy { it.sentAtNs } + val myReactions = mutableMapOf() + for (reactionMsg in sortedReactions) { + val reaction = reactionMsg.content() ?: continue + if (reactionMsg.senderInboxId == ClientManager.client.inboxId) { + when (reaction.action) { + ReactionAction.Added -> myReactions[reaction.content] = true + ReactionAction.Removed -> myReactions[reaction.content] = false + else -> {} + } } } + myExistingReaction = myReactions.entries.firstOrNull { it.value }?.key + } + + MessageOptionsSheet( + state = MessageOptionsState( + messageId = message.id, + canEdit = canEdit, + canDelete = canDelete, + currentReaction = myExistingReaction, + ), + onDismiss = { + showMessageOptions = false + selectedMessage = null + }, + onReply = { + viewModel.setReplyToMessage(message) + showMessageOptions = false + selectedMessage = null + }, + onEdit = { + Toast.makeText(context, "Edit feature coming soon", Toast.LENGTH_SHORT).show() + showMessageOptions = false + selectedMessage = null + }, + onDelete = { + showMessageOptions = false + showDeleteConfirmation = true + }, + onReaction = { emoji -> + scope.launch { + val result = when { + myExistingReaction == emoji -> { + viewModel.sendReaction(message.id, emoji, isRemoving = true) + } + myExistingReaction != null -> { + viewModel.sendReaction(message.id, myExistingReaction!!, isRemoving = true) + viewModel.sendReaction(message.id, emoji, isRemoving = false) + } + else -> { + viewModel.sendReaction(message.id, emoji, isRemoving = false) + } + } + when (result) { + is ConversationDetailViewModel.ReactionState.Success -> { + viewModel.fetchMessages() + } + is ConversationDetailViewModel.ReactionState.Error -> { + Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show() + } + else -> {} + } + } + showMessageOptions = false + selectedMessage = null + }, + ) + } - is ConversationDetailViewModel.UiState.Success -> { - binding.refresh.isRefreshing = false - adapter.setData(uiState.listItems) + // Delete Confirmation Dialog + if (showDeleteConfirmation && selectedMessage != null) { + DeleteConfirmationDialog( + onConfirm = { + scope.launch { + viewModel.deleteMessage(selectedMessage!!.id).collect { state -> + when (state) { + is ConversationDetailViewModel.DeleteMessageState.Success -> { + Toast.makeText(context, "Message deleted", Toast.LENGTH_SHORT).show() + viewModel.fetchMessages() + } + is ConversationDetailViewModel.DeleteMessageState.Error -> { + Toast.makeText(context, state.message, Toast.LENGTH_SHORT).show() + } + else -> {} + } + } + } + showDeleteConfirmation = false + selectedMessage = null + }, + onDismiss = { + showDeleteConfirmation = false + selectedMessage = null + }, + ) + } +} + +@Composable +private fun ConversationHeader( + title: String, + subtitle: String, + avatarText: String, + avatarColor: Color, + onBackClick: () -> Unit, + onHeaderClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) } - is ConversationDetailViewModel.UiState.Error -> { - binding.refresh.isRefreshing = false - showError(uiState.message) + Row( + modifier = Modifier + .weight(1f) + .clickable(onClick = onHeaderClick) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = avatarText, + style = MaterialTheme.typography.titleSmall, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } +} + +@Composable +private fun AttachmentPickerContent( + onCameraClick: () -> Unit, + onGalleryClick: () -> Unit, + onFileClick: () -> Unit, + onGifClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Text( + text = "Send attachment", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AttachmentOption( + icon = Icons.Default.CameraAlt, + label = "Camera", + onClick = onCameraClick, + ) + AttachmentOption( + icon = Icons.Default.Photo, + label = "Gallery", + onClick = onGalleryClick, + ) + AttachmentOption( + icon = Icons.Default.Description, + label = "File", + onClick = onFileClick, + ) + AttachmentOption( + icon = Icons.Default.Gif, + label = "GIF", + onClick = onGifClick, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} - private fun ensureSendState(sendState: ConversationDetailViewModel.SendMessageState) { - when (sendState) { - is ConversationDetailViewModel.SendMessageState.Error -> { - showError(sendState.message) +@Composable +private fun AttachmentOption( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clickable(onClick = onClick) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + modifier = Modifier.size(56.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + ) + } +} - ConversationDetailViewModel.SendMessageState.Loading -> { - binding.sendButton.isEnabled = false - binding.messageEditText.isEnabled = false - } +private fun convertToDisplayItem( + message: DecodedMessageV2, + isFromMe: Boolean, + avatarColors: List, +): MessageDisplayItem { + val content = message.content() + + val messageContent = when (content) { + is String -> content + is Reply -> (content.content as? String) ?: "Message" + is Attachment -> if (content.mimeType.startsWith("image/")) { + if (content.mimeType == "image/gif") "GIF" else "Photo" + } else "Attachment: ${content.filename}" + is DeletedMessage -> "" + else -> message.fallbackText ?: "Message" + } + + val isDeleted = content is DeletedMessage - ConversationDetailViewModel.SendMessageState.Success -> { - binding.messageEditText.text.clear() - binding.messageEditText.isEnabled = true - binding.sendButton.isEnabled = true - viewModel.fetchMessages() + // Get reactions + val reactions = if (message.hasReactions) { + val sortedReactions = message.reactions.sortedBy { it.sentAtNs } + val activeReactions = mutableMapOf>() + + for (reactionMsg in sortedReactions) { + val reaction = reactionMsg.content() ?: continue + when (reaction.action) { + ReactionAction.Added -> { + activeReactions.getOrPut(reaction.content) { mutableSetOf() }.add(reactionMsg.senderInboxId) + } + ReactionAction.Removed -> { + activeReactions[reaction.content]?.remove(reactionMsg.senderInboxId) + } + else -> {} } } + + activeReactions.filter { it.value.isNotEmpty() } + .map { (emoji, senders) -> emoji to senders.size } + } else { + null } - private fun addStreamedItem(item: ConversationDetailViewModel.MessageListItem?) { - item?.let { - adapter.addItem(it) + // Get reply info from content if it's a Reply type + val (replyPreview, replyAuthor) = when (content) { + is Reply -> { + // Get the reply content text + val replyText = when (val replyContent = content.content) { + is String -> replyContent + else -> "Message" + } + // Try to get author from inReplyTo if available + val author = content.inReplyTo?.senderInboxId?.let { it.take(8) + "..." } + replyText to author } + else -> null to null } - private fun showError(message: String) { - val error = message.ifBlank { resources.getString(R.string.error) } - Toast.makeText(this, error, Toast.LENGTH_SHORT).show() + val senderName = if (!isFromMe) message.senderInboxId.take(8) + "..." else null + val senderColor = if (!isFromMe) { + avatarColors[abs(message.senderInboxId.hashCode()) % avatarColors.size] + } else null + + return MessageDisplayItem( + id = message.id, + message = messageContent, + timestamp = formatMessageTime(message.sentAtNs / 1_000_000), + isMe = isFromMe, + senderName = senderName, + senderColor = senderColor, + isSystemMessage = false, + reactions = reactions, + replyPreview = replyPreview, + replyAuthor = replyAuthor, + isDeleted = isDeleted, + originalMessage = message, + ) +} + +private fun formatMessageTime(timestampMs: Long): String { + val messageDate = Date(timestampMs) + val now = Calendar.getInstance() + val messageCalendar = Calendar.getInstance().apply { time = messageDate } + + return when { + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate) + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> { + "Yesterday" + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> { + SimpleDateFormat("EEE", Locale.getDefault()).format(messageDate) + } + else -> { + SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate) + } } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt index 9c8c24d09..c927f7aba 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.annotation.UiThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.protobuf.kotlin.toByteString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -15,11 +16,29 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.xmtp.android.example.ClientManager import org.xmtp.android.example.extension.flowWhileShared import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.library.Conversation +import org.xmtp.android.library.SendOptions +import org.xmtp.android.library.codecs.Attachment +import org.xmtp.android.library.codecs.ContentTypeAttachment +import org.xmtp.android.library.codecs.ContentTypeReaction +import org.xmtp.android.library.codecs.ContentTypeReply +import org.xmtp.android.library.codecs.ContentTypeText +import org.xmtp.android.library.codecs.DeletedBy +import org.xmtp.android.library.codecs.DeletedMessage +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.codecs.ReactionSchema +import org.xmtp.android.library.codecs.Reply +import org.xmtp.android.library.codecs.ReplyCodec +import org.xmtp.android.library.codecs.TextCodec +import org.xmtp.android.library.libxmtp.DecodedMessageV2 +import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated +import timber.log.Timber +import org.xmtp.android.library.libxmtp.Reply as EnrichedReply class ConversationDetailViewModel( private val savedStateHandle: SavedStateHandle, @@ -30,7 +49,9 @@ class ConversationDetailViewModel( null, ) - private val conversationTopic = conversationTopicFlow.value + // Read from flow to get the current value (supports updates via setConversationTopic) + private val conversationTopic: String? + get() = conversationTopicFlow.value fun setConversationTopic(conversationTopic: String?) { savedStateHandle[ConversationDetailActivity.EXTRA_CONVERSATION_TOPIC] = conversationTopic @@ -39,10 +60,44 @@ class ConversationDetailViewModel( private val _uiState = MutableStateFlow(UiState.Loading(null)) val uiState: StateFlow = _uiState + private val _replyToMessage = MutableStateFlow(null) + val replyToMessage: StateFlow = _replyToMessage + + private val _editingMessage = MutableStateFlow(null) + val editingMessage: StateFlow = _editingMessage + + // Instance-level setting - set from Activity based on user preferences + private val _hideDeletedMessages = MutableStateFlow(false) + + fun setHideDeletedMessages(hide: Boolean) { + _hideDeletedMessages.value = hide + } + private var conversation: Conversation? = null + fun setReplyToMessage(message: DecodedMessageV2?) { + _replyToMessage.value = message + } + + fun clearReply() { + _replyToMessage.value = null + } + + fun setEditingMessage(message: DecodedMessageV2?) { + _editingMessage.value = message + } + + fun clearEdit() { + _editingMessage.value = null + } + @UiThread fun fetchMessages() { + val topic = conversationTopic + if (topic == null) { + _uiState.value = UiState.Error("Conversation topic not set") + return + } when (val uiState = uiState.value) { is UiState.Success -> _uiState.value = UiState.Loading(uiState.listItems) else -> _uiState.value = UiState.Loading(null) @@ -51,15 +106,36 @@ class ConversationDetailViewModel( val listItems = mutableListOf() try { if (conversation == null) { - conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) + conversation = ClientManager.client.conversations.findConversationByTopic(topic) } - conversation?.let { - if (conversation is Conversation.Group) { - (conversation as Conversation.Group).group.sync() - } + conversation?.let { conv -> + // Sync conversation to get latest messages (including deletions) + val isDm = + when (conv) { + is Conversation.Group -> { + conv.group.sync() + false + } + + is Conversation.Dm -> { + conv.dm.sync() + true + } + } + val shouldHideDeleted = _hideDeletedMessages.value listItems.addAll( - it.messages().map { message -> - MessageListItem.Message(message.id, message) + conv.enrichedMessages().mapNotNull { message -> + message?.let { msg -> + val item = classifyMessage(msg, isDm) ?: return@mapNotNull null + // Filter out deleted messages if hideDeletedMessages is enabled + if (shouldHideDeleted && item is MessageListItem.SystemMessage) { + val content = msg.content() + if (content is DeletedMessage) { + return@mapNotNull null + } + } + item + } }, ) } @@ -71,36 +147,93 @@ class ConversationDetailViewModel( } @OptIn(ExperimentalCoroutinesApi::class) - val streamMessages: StateFlow = + val streamMessages: StateFlow = stateFlow(viewModelScope, null) { subscriptionCount -> - if (conversation == null) { - conversation = - runBlocking { - ClientManager.client.conversations.findConversationByTopic(conversationTopic!!) - } - } - if (conversation != null) { - conversation!! - .streamMessages() - .flowWhileShared( - subscriptionCount, - SharingStarted.WhileSubscribed(1000L), - ).flowOn(Dispatchers.IO) - .distinctUntilChanged() - .mapLatest { message -> - MessageListItem.Message(message.id, message) - }.catch { emptyFlow() } - } else { + val topic = conversationTopic + if (topic == null) { emptyFlow() + } else { + // Ensure conversation is initialized + if (conversation == null) { + conversation = ClientManager.client.conversations.findConversationByTopic(topic) + } + val conv = conversation + if (conv != null) { + val isDm = conv is Conversation.Dm + conv.streamMessages() + .flowWhileShared( + subscriptionCount, + SharingStarted.WhileSubscribed(1000L), + ).flowOn(Dispatchers.IO) + .distinctUntilChanged() + .mapLatest { message -> + // Check if this is a delete, reaction, or edit message - if so, signal a refresh is needed + val contentTypeId = message.encodedContent.type + val isDeleteMessage = contentTypeId?.typeId == "deleteMessage" + val isReactionMessage = contentTypeId?.typeId == "reaction" + val isEditMessage = contentTypeId?.typeId == "editMessage" + + if (isDeleteMessage || isReactionMessage || isEditMessage) { + // Return a signal to refresh the message list + // Reactions, deletes, and edits modify existing messages, so we need a full refresh + StreamedMessageResult.RefreshNeeded + } else { + // Convert streamed DecodedMessage to DecodedMessageV2 using findEnrichedMessage + val enrichedMessage = + ClientManager.client.conversations.findEnrichedMessage(message.id) + enrichedMessage?.let { + classifyMessage(it, isDm)?.let { item -> + StreamedMessageResult.NewMessage(item) + } + } + } + }.catch { e -> + Timber.e(e, "Error in message stream") + emit(null) + } + } else { + emptyFlow() + } } } + sealed class StreamedMessageResult { + data class NewMessage( + val item: MessageListItem, + ) : StreamedMessageResult() + + data object RefreshNeeded : StreamedMessageResult() + } + @UiThread fun sendMessage(body: String): StateFlow { val flow = MutableStateFlow(SendMessageState.Loading) + val replyTo = _replyToMessage.value + val editMessage = _editingMessage.value viewModelScope.launch(Dispatchers.IO) { try { - conversation?.send(body) + if (editMessage != null) { + // Edit message feature is not yet released in the library + // Clear editing state and show error + _editingMessage.value = null + throw UnsupportedOperationException("Edit message feature is not yet available") + } else if (replyTo != null) { + // Send as reply using Reply codec + val replyContent = + Reply( + reference = replyTo.id, + content = body, + contentType = ContentTypeText, + ) + conversation?.send( + content = replyContent, + options = SendOptions(contentType = ContentTypeReply), + ) + _replyToMessage.value = null + } else { + // Send as regular message + conversation?.send(body) + } flow.value = SendMessageState.Success } catch (e: Exception) { flow.value = SendMessageState.Error(e.localizedMessage.orEmpty()) @@ -109,6 +242,82 @@ class ConversationDetailViewModel( return flow } + @UiThread + fun deleteMessage(messageId: String): StateFlow { + val flow = MutableStateFlow(DeleteMessageState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + conversation?.deleteMessage(messageId) + flow.value = DeleteMessageState.Success + } catch (e: Exception) { + flow.value = DeleteMessageState.Error(e.localizedMessage.orEmpty()) + } + } + return flow + } + + suspend fun sendReaction( + messageId: String, + emoji: String, + isRemoving: Boolean = false, + ): ReactionState = + withContext(Dispatchers.IO) { + try { + val reaction = + Reaction( + reference = messageId, + action = if (isRemoving) ReactionAction.Removed else ReactionAction.Added, + content = emoji, + schema = ReactionSchema.Unicode, + ) + conversation?.send( + content = reaction, + options = SendOptions(contentType = ContentTypeReaction), + ) + // Sync to ensure the reaction is persisted and available for enrichedMessages + conversation?.let { + when (it) { + is Conversation.Group -> it.group.sync() + is Conversation.Dm -> it.dm.sync() + } + } + ReactionState.Success + } catch (e: Exception) { + ReactionState.Error(e.localizedMessage.orEmpty()) + } + } + + suspend fun sendAttachment( + filename: String, + mimeType: String, + data: ByteArray, + ): SendAttachmentState = + withContext(Dispatchers.IO) { + try { + Timber.d("sendAttachment: filename=$filename, mimeType=$mimeType, dataSize=${data.size}") + val attachment = + Attachment( + filename = filename, + mimeType = mimeType, + data = data.toByteString(), + ) + Timber.d("sendAttachment: created Attachment object, conversation=$conversation") + if (conversation == null) { + Timber.e("sendAttachment: conversation is null!") + return@withContext SendAttachmentState.Error("Conversation not found") + } + conversation?.send( + content = attachment, + options = SendOptions(contentType = ContentTypeAttachment), + ) + Timber.d("sendAttachment: sent successfully") + SendAttachmentState.Success + } catch (e: Exception) { + Timber.e(e, "sendAttachment: failed") + SendAttachmentState.Error(e.localizedMessage.orEmpty()) + } + } + sealed class UiState { data class Loading( val listItems: List?, @@ -124,26 +333,139 @@ class ConversationDetailViewModel( } sealed class SendMessageState { - object Loading : SendMessageState() + data object Loading : SendMessageState() - object Success : SendMessageState() + data object Success : SendMessageState() data class Error( val message: String, ) : SendMessageState() } + sealed class DeleteMessageState { + data object Loading : DeleteMessageState() + + data object Success : DeleteMessageState() + + data class Error( + val message: String, + ) : DeleteMessageState() + } + + sealed class ReactionState { + data object Loading : ReactionState() + + data object Success : ReactionState() + + data class Error( + val message: String, + ) : ReactionState() + } + + sealed class SendAttachmentState { + data object Loading : SendAttachmentState() + + data object Success : SendAttachmentState() + + data class Error( + val message: String, + ) : SendAttachmentState() + } + sealed class MessageListItem( open val id: String, val itemType: Int, ) { companion object { - const val ITEM_TYPE_MESSAGE = 1 + const val ITEM_TYPE_SENT = 1 + const val ITEM_TYPE_RECEIVED = 2 + const val ITEM_TYPE_SYSTEM = 3 } - data class Message( + data class SentMessage( + override val id: String, + val message: DecodedMessageV2, + ) : MessageListItem(id, ITEM_TYPE_SENT) + + data class ReceivedMessage( override val id: String, - val message: org.xmtp.android.library.libxmtp.DecodedMessage, - ) : MessageListItem(id, ITEM_TYPE_MESSAGE) + val message: DecodedMessageV2, + ) : MessageListItem(id, ITEM_TYPE_RECEIVED) + + data class SystemMessage( + override val id: String, + val message: DecodedMessageV2, + val text: String, + ) : MessageListItem(id, ITEM_TYPE_SYSTEM) + } + + companion object { + // Protocol message types that should not be displayed in the UI + private val HIDDEN_CONTENT_TYPES = setOf("editMessage", "deleteMessage", "reaction") + + fun classifyMessage( + message: DecodedMessageV2, + isDm: Boolean = false, + ): MessageListItem? { + // Filter out protocol messages that modify other messages + val contentTypeId = message.contentTypeId.typeId + if (contentTypeId in HIDDEN_CONTENT_TYPES) { + return null + } + + val content = message.content() + val isFromMe = ClientManager.client.inboxId == message.senderInboxId + + // Check for system/protocol messages + return when (content) { + // Placeholder for deleted message content (shown in UI) + is DeletedMessage -> { + val deletedByText = + when (content.deletedBy) { + is DeletedBy.Sender -> "sender" + is DeletedBy.Admin -> "admin" + } + MessageListItem.SystemMessage( + message.id, + message, + "This message was deleted by $deletedByText", + ) + } + + is GroupUpdated -> { + // For DMs, show "Conversation started by [initiator]" instead of member changes + val text = + if (isDm) { + val initiatorId = content.initiatedByInboxId + if (initiatorId.isNotEmpty()) { + "Conversation started by ${initiatorId.take(8)}..." + } else { + "Conversation started" + } + } else { + val addedText = + content.addedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Added: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + val removedText = + content.removedInboxesList + ?.mapNotNull { it.inboxId } + ?.takeIf { it.isNotEmpty() } + ?.let { "Removed: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" } + listOfNotNull(addedText, removedText).joinToString("\n").ifEmpty { "Group updated" } + } + MessageListItem.SystemMessage(message.id, message, text) + } + + else -> { + if (isFromMe) { + MessageListItem.SentMessage(message.id, message) + } else { + MessageListItem.ReceivedMessage(message.id, message) + } + } + } + } } } diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt deleted file mode 100644 index 0142c7dad..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.xmtp.android.example.conversation - -import android.graphics.Color -import android.graphics.Typeface -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.MainViewModel -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.ListItemConversationFooterBinding - -class ConversationFooterViewHolder( - private val binding: ListItemConversationFooterBinding, - onFooterClickListener: ConversationsClickListener, -) : RecyclerView.ViewHolder(binding.root) { - private var address: String? = null - - init { - binding.root.setOnClickListener { - address?.let { - onFooterClickListener.onFooterClick(it) - } - } - } - - fun bind(item: MainViewModel.MainListItem.Footer) { - address = item.address - val spannable = - SpannableString( - binding.root.resources.getString( - R.string.conversation_footer, - item.address, - item.environment, - ), - ) - val addressStart = spannable.indexOf(item.address) - val envStart = spannable.indexOf(item.environment) - spannable.setSpan( - StyleSpan(Typeface.BOLD), - addressStart, - addressStart + item.address.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - ForegroundColorSpan(Color.BLACK), - addressStart, - addressStart + item.address.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - StyleSpan(Typeface.BOLD), - envStart, - envStart + item.environment.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - spannable.setSpan( - ForegroundColorSpan(Color.BLACK), - envStart, - envStart + item.environment.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE, - ) - binding.footer.text = spannable - } -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt deleted file mode 100644 index 681debbc2..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.xmtp.android.example.conversation - -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.ClientManager -import org.xmtp.android.example.MainViewModel -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.ListItemConversationBinding -import org.xmtp.android.example.extension.truncatedAddress -import org.xmtp.android.library.Conversation -import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated - -class ConversationViewHolder( - private val binding: ListItemConversationBinding, - clickListener: ConversationsClickListener, -) : RecyclerView.ViewHolder(binding.root) { - private var conversation: Conversation? = null - - init { - binding.root.setOnClickListener { - conversation?.let { - clickListener.onConversationClick(it) - } - } - } - - fun bind(item: MainViewModel.MainListItem.ConversationItem) { - conversation = item.conversation - binding.peerAddress.text = item.conversation.id.truncatedAddress() - - val messageBody: String = - if (item.mostRecentMessage?.content() is String) { - item.mostRecentMessage.body.orEmpty() - } else if (item.mostRecentMessage?.content() is GroupUpdated) { - val changes = item.mostRecentMessage.content() as? GroupUpdated - "Membership Changed ${ - changes?.addedInboxesList?.mapNotNull { it.inboxId } - }" - } else { - "" - } - val isMe = item.mostRecentMessage?.senderInboxId == ClientManager.client.inboxId - if (messageBody.isNotBlank()) { - binding.messageBody.text = - if (isMe) { - binding.root.resources.getString( - R.string.your_message_body, - messageBody, - ) - } else { - messageBody - } - } else { - binding.messageBody.text = binding.root.resources.getString(R.string.empty_message) - } - } -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt deleted file mode 100644 index c9843bf31..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.xmtp.android.example.conversation - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.MainViewModel -import org.xmtp.android.example.databinding.ListItemConversationBinding -import org.xmtp.android.example.databinding.ListItemConversationFooterBinding - -class ConversationsAdapter( - private val clickListener: ConversationsClickListener, -) : RecyclerView.Adapter() { - init { - setHasStableIds(true) - } - - private val listItems = mutableListOf() - - fun setData(newItems: List) { - listItems.clear() - listItems.addAll(newItems) - notifyDataSetChanged() - } - - fun addItem(item: MainViewModel.MainListItem) { - listItems.add(0, item) - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - MainViewModel.MainListItem.ITEM_TYPE_CONVERSATION -> { - val binding = ListItemConversationBinding.inflate(inflater, parent, false) - ConversationViewHolder(binding, clickListener) - } - MainViewModel.MainListItem.ITEM_TYPE_FOOTER -> { - val binding = ListItemConversationFooterBinding.inflate(inflater, parent, false) - ConversationFooterViewHolder(binding, clickListener) - } - else -> throw IllegalArgumentException("Unsupported view type $viewType") - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - val item = listItems[position] - when (holder) { - is ConversationViewHolder -> { - holder.bind(item as MainViewModel.MainListItem.ConversationItem) - } - is ConversationFooterViewHolder -> { - holder.bind(item as MainViewModel.MainListItem.Footer) - } - else -> throw IllegalArgumentException("Unsupported view holder") - } - } - - override fun getItemViewType(position: Int) = listItems[position].itemType - - override fun getItemCount() = listItems.count() - - override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong() -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt deleted file mode 100644 index 0e2d9db86..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.xmtp.android.example.conversation - -import org.xmtp.android.library.Conversation - -interface ConversationsClickListener { - fun onConversationClick(conversation: Conversation) - - fun onFooterClick(address: String) -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt new file mode 100644 index 000000000..a8f8bd4e5 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt @@ -0,0 +1,345 @@ +package org.xmtp.android.example.conversation + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import org.xmtp.android.example.ui.components.MemberPermissionLevel +import org.xmtp.android.example.ui.components.MemberRowData +import org.xmtp.android.example.ui.screens.GroupDetails +import org.xmtp.android.example.ui.screens.GroupManagementScreen +import org.xmtp.android.example.ui.theme.XMTPTheme +import org.xmtp.android.library.libxmtp.PermissionLevel +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.regex.Pattern + +class GroupManagementActivity : ComponentActivity() { + private val viewModel: GroupManagementViewModel by viewModels() + + companion object { + const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC" + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") + + fun intent( + context: Context, + topic: String, + ): Intent = + Intent(context, GroupManagementActivity::class.java).apply { + putExtra(EXTRA_CONVERSATION_TOPIC, topic) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC)) + + setContent { + XMTPTheme { + val uiState by viewModel.uiState.collectAsState() + val scope = rememberCoroutineScope() + + var showAddMemberDialog by remember { mutableStateOf(false) } + var addMemberAddress by remember { mutableStateOf("") } + var showLeaveGroupDialog by remember { mutableStateOf(false) } + var showConfirmationDialog by remember { mutableStateOf(null) } + + // Load group info on first composition + LaunchedEffect(Unit) { + viewModel.loadGroupInfo() + } + + // Handle action state results + fun handleActionState(state: GroupManagementViewModel.ActionState) { + when (state) { + is GroupManagementViewModel.ActionState.Success -> { + Toast.makeText( + this@GroupManagementActivity, + state.message, + Toast.LENGTH_SHORT, + ).show() + } + is GroupManagementViewModel.ActionState.Error -> { + Toast.makeText( + this@GroupManagementActivity, + state.message.ifBlank { "Error" }, + Toast.LENGTH_SHORT, + ).show() + } + is GroupManagementViewModel.ActionState.Loading -> Unit + } + } + + val isLoading = uiState is GroupManagementViewModel.UiState.Loading + + val groupDetails = when (val state = uiState) { + is GroupManagementViewModel.UiState.Success -> { + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + val createdAtString = state.createdAt?.let { dateFormat.format(it) } ?: "" + GroupDetails( + groupId = state.groupId.truncatedAddress(), + groupName = state.groupId, + createdAt = createdAtString, + memberCount = state.members.size, + currentUserRole = state.currentUserRole.toMemberPermissionLevel(), + ) + } + else -> GroupDetails( + groupId = "", + groupName = "", + createdAt = "", + memberCount = 0, + currentUserRole = MemberPermissionLevel.MEMBER, + ) + } + + val members = when (val state = uiState) { + is GroupManagementViewModel.UiState.Success -> { + state.members.map { member -> + MemberRowData( + inboxId = member.member.inboxId, + displayAddress = member.displayAddress, + permissionLevel = member.member.permissionLevel.toMemberPermissionLevel(), + isCurrentUser = member.isCurrentUser, + ) + } + } + else -> emptyList() + } + + // Handle error state + LaunchedEffect(uiState) { + if (uiState is GroupManagementViewModel.UiState.Error) { + Toast.makeText( + this@GroupManagementActivity, + (uiState as GroupManagementViewModel.UiState.Error).message, + Toast.LENGTH_SHORT, + ).show() + } + } + + GroupManagementScreen( + groupDetails = groupDetails, + members = members, + isLoading = isLoading, + onBackClick = { finish() }, + onAddMemberClick = { showAddMemberDialog = true }, + onMemberClick = { inboxId -> + // Find member's display address + val member = members.find { it.inboxId == inboxId } + member?.displayAddress?.let { address -> + startActivity( + UserProfileActivity.intent( + this@GroupManagementActivity, + address, + inboxId, + ), + ) + } + }, + onPromoteToAdmin = { inboxId -> + val member = members.find { m -> m.inboxId == inboxId } + val displayName = member?.displayAddress?.truncatedAddress() + ?: inboxId.truncatedAddress() + showConfirmationDialog = ConfirmationDialogData( + title = "Promote to Admin", + message = "Are you sure you want to promote $displayName to admin?", + confirmText = "Promote", + onConfirm = { + scope.launch { + viewModel.promoteToAdmin(inboxId).collect { state -> + handleActionState(state) + } + } + }, + ) + }, + onDemoteFromAdmin = { inboxId -> + val member = members.find { m -> m.inboxId == inboxId } + val displayName = member?.displayAddress?.truncatedAddress() + ?: inboxId.truncatedAddress() + showConfirmationDialog = ConfirmationDialogData( + title = "Remove Admin", + message = "Are you sure you want to remove admin privileges from $displayName?", + confirmText = "Remove", + onConfirm = { + scope.launch { + viewModel.demoteFromAdmin(inboxId).collect { state -> + handleActionState(state) + } + } + }, + ) + }, + onRemoveMember = { inboxId -> + val member = members.find { m -> m.inboxId == inboxId } + val displayName = member?.displayAddress?.truncatedAddress() + ?: inboxId.truncatedAddress() + showConfirmationDialog = ConfirmationDialogData( + title = "Remove Member", + message = "Are you sure you want to remove $displayName from this group?", + confirmText = "Remove", + onConfirm = { + scope.launch { + viewModel.removeMember(inboxId).collect { state -> + handleActionState(state) + } + } + }, + ) + }, + onLeaveGroup = { showLeaveGroupDialog = true }, + ) + + // Add member dialog + if (showAddMemberDialog) { + AlertDialog( + onDismissRequest = { + showAddMemberDialog = false + addMemberAddress = "" + }, + title = { Text("Add Member") }, + text = { + OutlinedTextField( + value = addMemberAddress, + onValueChange = { addMemberAddress = it }, + label = { Text("Wallet Address") }, + placeholder = { Text("0x...") }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { + val address = addMemberAddress.trim() + if (ADDRESS_PATTERN.matcher(address).matches()) { + scope.launch { + viewModel.addMember(address).collect { state -> + handleActionState(state) + } + } + showAddMemberDialog = false + addMemberAddress = "" + } else { + Toast.makeText( + this@GroupManagementActivity, + "Invalid address", + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Text("Add") + } + }, + dismissButton = { + TextButton( + onClick = { + showAddMemberDialog = false + addMemberAddress = "" + }, + ) { + Text("Cancel") + } + }, + ) + } + + // Leave group confirmation dialog + if (showLeaveGroupDialog) { + AlertDialog( + onDismissRequest = { showLeaveGroupDialog = false }, + title = { Text("Leave Group") }, + text = { Text("Are you sure you want to leave this group?") }, + confirmButton = { + TextButton( + onClick = { + showLeaveGroupDialog = false + // TODO: Implement leave group when API supports it + Toast.makeText( + this@GroupManagementActivity, + "Leave group not yet implemented", + Toast.LENGTH_SHORT, + ).show() + }, + ) { + Text("Leave") + } + }, + dismissButton = { + TextButton(onClick = { showLeaveGroupDialog = false }) { + Text("Cancel") + } + }, + ) + } + + // Generic confirmation dialog + showConfirmationDialog?.let { dialogData -> + AlertDialog( + onDismissRequest = { showConfirmationDialog = null }, + title = { Text(dialogData.title) }, + text = { Text(dialogData.message) }, + confirmButton = { + TextButton( + onClick = { + dialogData.onConfirm() + showConfirmationDialog = null + }, + ) { + Text(dialogData.confirmText) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmationDialog = null }) { + Text("Cancel") + } + }, + ) + } + } + } + } +} + +private data class ConfirmationDialogData( + val title: String, + val message: String, + val confirmText: String, + val onConfirm: () -> Unit, +) + +private fun PermissionLevel.toMemberPermissionLevel(): MemberPermissionLevel { + return when (this) { + PermissionLevel.SUPER_ADMIN -> MemberPermissionLevel.SUPER_ADMIN + PermissionLevel.ADMIN -> MemberPermissionLevel.ADMIN + PermissionLevel.MEMBER -> MemberPermissionLevel.MEMBER + } +} + +private fun String.truncatedAddress(): String { + if (length >= 10) { + val start = 6 + val end = length - 4 + return replaceRange(start, end, "...") + } + return this +} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt new file mode 100644 index 000000000..818cb9c94 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt @@ -0,0 +1,184 @@ +package org.xmtp.android.example.conversation + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.xmtp.android.example.ClientManager +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.Group +import org.xmtp.android.library.libxmtp.IdentityKind +import org.xmtp.android.library.libxmtp.PermissionLevel +import org.xmtp.android.library.libxmtp.PublicIdentity + +class GroupManagementViewModel( + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val conversationTopicFlow = + savedStateHandle.getStateFlow( + GroupManagementActivity.EXTRA_CONVERSATION_TOPIC, + null, + ) + + fun setConversationTopic(topic: String?) { + savedStateHandle[GroupManagementActivity.EXTRA_CONVERSATION_TOPIC] = topic + } + + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState + + private var group: Group? = null + + fun loadGroupInfo() { + viewModelScope.launch(Dispatchers.IO) { + try { + val topic = conversationTopicFlow.value ?: throw Exception("No topic provided") + val conversation = + ClientManager.client.conversations.findConversationByTopic(topic) + ?: throw Exception("Conversation not found") + + if (conversation !is Conversation.Group) { + throw Exception("Not a group conversation") + } + + group = conversation.group + group?.sync() + + val members = group?.members() ?: emptyList() + val currentUserInboxId = ClientManager.client.inboxId + + // Get addresses for members + val memberItems = + members + .map { member -> + val addresses = member.identities.mapNotNull { it.identifier } + MemberItem( + member = member, + isCurrentUser = member.inboxId == currentUserInboxId, + displayAddress = addresses.firstOrNull(), + ) + }.sortedWith( + compareBy { !it.isCurrentUser } + .thenBy { + when (it.member.permissionLevel) { + PermissionLevel.SUPER_ADMIN -> 0 + PermissionLevel.ADMIN -> 1 + PermissionLevel.MEMBER -> 2 + } + }, + ) + + val currentUserMember = members.find { it.inboxId == currentUserInboxId } + val currentUserRole = currentUserMember?.permissionLevel ?: PermissionLevel.MEMBER + val canManageMembers = + currentUserRole == PermissionLevel.SUPER_ADMIN || + currentUserRole == PermissionLevel.ADMIN + + _uiState.value = + UiState.Success( + groupId = group?.id ?: "", + createdAt = group?.createdAt, + members = memberItems, + currentUserRole = currentUserRole, + canManageMembers = canManageMembers, + ) + } catch (e: Exception) { + _uiState.value = UiState.Error(e.localizedMessage ?: "Unknown error") + } + } + } + + fun addMember(address: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, address) + group?.addMembersByIdentity(listOf(publicIdentity)) + flow.value = ActionState.Success("Member added successfully") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to add member") + } + } + return flow + } + + fun removeMember(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.removeMembers(listOf(inboxId)) + flow.value = ActionState.Success("Member removed successfully") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to remove member") + } + } + return flow + } + + fun promoteToAdmin(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.addAdmin(inboxId) + flow.value = ActionState.Success("Member promoted to admin") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to promote member") + } + } + return flow + } + + fun demoteFromAdmin(inboxId: String): StateFlow { + val flow = MutableStateFlow(ActionState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + group?.removeAdmin(inboxId) + flow.value = ActionState.Success("Admin demoted to member") + loadGroupInfo() // Refresh + } catch (e: Exception) { + flow.value = ActionState.Error(e.localizedMessage ?: "Failed to demote admin") + } + } + return flow + } + + sealed class UiState { + object Loading : UiState() + + data class Success( + val groupId: String, + val createdAt: java.util.Date?, + val members: List, + val currentUserRole: PermissionLevel, + val canManageMembers: Boolean, + ) : UiState() + + data class Error( + val message: String, + ) : UiState() + } + + sealed class ActionState { + object Loading : ActionState() + + data class Success( + val message: String, + ) : ActionState() + + data class Error( + val message: String, + ) : ActionState() + } +} + +data class MemberItem( + val member: org.xmtp.android.library.libxmtp.Member, + val isCurrentUser: Boolean, + val displayAddress: String?, +) diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt new file mode 100644 index 000000000..a21b46f30 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt @@ -0,0 +1,155 @@ +package org.xmtp.android.example.conversation + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.xmtp.android.example.ui.components.RecentContactData +import org.xmtp.android.example.ui.screens.ConversationType +import org.xmtp.android.example.ui.screens.NewConversationScreen +import org.xmtp.android.example.ui.theme.XMTPTheme +import java.util.regex.Pattern + +class NewConversationActivity : ComponentActivity() { + private val viewModel: NewConversationViewModel by viewModels() + + companion object { + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$") + + fun intent(context: Context): Intent = Intent(context, NewConversationActivity::class.java) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + viewModel.loadRecentContacts() + + setContent { + XMTPTheme { + var addressInput by remember { mutableStateOf("") } + var groupName by remember { mutableStateOf("") } + var conversationType by remember { mutableStateOf(ConversationType.DM) } + val selectedMembers = remember { mutableStateListOf() } + + val uiState by viewModel.uiState.collectAsState() + val recentContacts by viewModel.recentContacts.collectAsState() + + val isLoading = uiState is NewConversationViewModel.UiState.Loading + + val isValidAddress = ADDRESS_PATTERN.matcher(addressInput.trim()).matches() + val canCreate = when (conversationType) { + ConversationType.DM -> isValidAddress + ConversationType.GROUP -> selectedMembers.isNotEmpty() + } + + // Convert RecentContact to RecentContactData + val recentContactsData = recentContacts.map { contact -> + RecentContactData( + address = contact.address, + lastActivityTimeNs = contact.lastActivityTime, + ) + } + + // Handle UI state changes + LaunchedEffect(uiState) { + when (uiState) { + is NewConversationViewModel.UiState.Success -> { + val conversation = (uiState as NewConversationViewModel.UiState.Success).conversation + startActivity( + ConversationDetailActivity.intent( + this@NewConversationActivity, + topic = conversation.topic, + peerAddress = conversation.id, + ), + ) + finish() + } + is NewConversationViewModel.UiState.Error -> { + val error = (uiState as NewConversationViewModel.UiState.Error).message + Toast.makeText( + this@NewConversationActivity, + error.ifBlank { "Error" }, + Toast.LENGTH_SHORT, + ).show() + } + else -> Unit + } + } + + NewConversationScreen( + addressInput = addressInput, + groupName = groupName, + selectedMembers = selectedMembers.toList(), + recentContacts = recentContactsData, + conversationType = conversationType, + isLoading = isLoading, + canCreate = canCreate, + onAddressChange = { addressInput = it }, + onGroupNameChange = { groupName = it }, + onConversationTypeChange = { newType -> + conversationType = newType + // Clear input and members when switching modes + addressInput = "" + selectedMembers.clear() + }, + onAddMember = { + val address = addressInput.trim() + if (ADDRESS_PATTERN.matcher(address).matches() && + !selectedMembers.contains(address.lowercase()) + ) { + selectedMembers.add(address.lowercase()) + addressInput = "" + } + }, + onRemoveMember = { address -> + selectedMembers.remove(address) + }, + onRecentContactClick = { address -> + if (conversationType == ConversationType.GROUP) { + val normalizedAddress = address.lowercase() + if (selectedMembers.contains(normalizedAddress)) { + selectedMembers.remove(normalizedAddress) + } else { + selectedMembers.add(normalizedAddress) + } + } else { + // In DM mode, start conversation directly + viewModel.createConversation(address) + } + }, + onClose = { finish() }, + onCreate = { + if (conversationType == ConversationType.GROUP) { + if (selectedMembers.isNotEmpty()) { + viewModel.createGroup(selectedMembers.toList(), groupName) + } + } else { + val address = addressInput.trim() + if (ADDRESS_PATTERN.matcher(address).matches()) { + viewModel.createConversation(address) + } + } + }, + ) + } + } + } +} + +data class RecentContact( + val address: String, + val inboxId: String?, + val lastActivityTime: Long, +) diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt deleted file mode 100644 index f88926a11..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.xmtp.android.example.conversation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.coroutines.launch -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.BottomSheetNewConversationBinding -import java.util.regex.Pattern - -class NewConversationBottomSheet : BottomSheetDialogFragment() { - private val viewModel: NewConversationViewModel by viewModels() - private var _binding: BottomSheetNewConversationBinding? = null - private val binding get() = _binding!! - - companion object { - const val TAG = "NewConversationBottomSheet" - - private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$") - - fun newInstance(): NewConversationBottomSheet = NewConversationBottomSheet() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = BottomSheetNewConversationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::ensureUiState) - } - } - - binding.addressInput.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - viewModel.createConversation(input.toString()) - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun ensureUiState(uiState: NewConversationViewModel.UiState) { - when (uiState) { - is NewConversationViewModel.UiState.Error -> { - binding.addressInput.isEnabled = true - binding.progress.visibility = View.GONE - showError(uiState.message) - } - NewConversationViewModel.UiState.Loading -> { - binding.addressInput.isEnabled = false - binding.progress.visibility = View.VISIBLE - } - is NewConversationViewModel.UiState.Success -> { - startActivity( - ConversationDetailActivity.intent( - requireContext(), - topic = uiState.conversation.topic, - peerAddress = uiState.conversation.id, - ), - ) - dismiss() - } - NewConversationViewModel.UiState.Unknown -> Unit - } - } - - private fun showError(message: String) { - val error = message.ifBlank { resources.getString(R.string.error) } - Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() - } -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt index 607e4374a..0bef97492 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt @@ -16,6 +16,9 @@ class NewConversationViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState.Unknown) val uiState: StateFlow = _uiState + private val _recentContacts = MutableStateFlow>(emptyList()) + val recentContacts: StateFlow> = _recentContacts + @UiThread fun createConversation(address: String) { _uiState.value = UiState.Loading @@ -36,18 +39,23 @@ class NewConversationViewModel : ViewModel() { } @UiThread - fun createGroup(addresses: List) { + fun createGroup( + addresses: List, + groupName: String = "", + ) { _uiState.value = UiState.Loading viewModelScope.launch(Dispatchers.IO) { try { val group = ClientManager.client.conversations.newGroupWithIdentities( - addresses.map { - PublicIdentity( - IdentityKind.ETHEREUM, - it, - ) - }, + identities = + addresses.map { + PublicIdentity( + IdentityKind.ETHEREUM, + it, + ) + }, + groupName = groupName, ) _uiState.value = UiState.Success(Conversation.Group(group)) } catch (e: Exception) { @@ -56,6 +64,72 @@ class NewConversationViewModel : ViewModel() { } } + @UiThread + fun loadRecentContacts() { + viewModelScope.launch(Dispatchers.IO) { + try { + val currentInboxId = ClientManager.client.inboxId + val conversations = ClientManager.client.conversations.list() + + // Get unique peer addresses from DM conversations + val contactsMap = mutableMapOf() + + for (conversation in conversations) { + when (conversation) { + is Conversation.Dm -> { + val members = conversation.dm.members() + val peerMember = members.find { it.inboxId != currentInboxId } + peerMember?.let { member -> + val address = member.identities.firstOrNull()?.identifier + if (address != null && !contactsMap.containsKey(address.lowercase())) { + contactsMap[address.lowercase()] = + RecentContact( + address = address, + inboxId = member.inboxId, + lastActivityTime = conversation.lastActivityNs, + ) + } + } + } + is Conversation.Group -> { + // Get all members from groups except current user + val members = conversation.group.members() + for (member in members) { + if (member.inboxId != currentInboxId) { + val address = member.identities.firstOrNull()?.identifier + if (address != null) { + val existingContact = contactsMap[address.lowercase()] + if (existingContact == null || + conversation.lastActivityNs > existingContact.lastActivityTime + ) { + contactsMap[address.lowercase()] = + RecentContact( + address = address, + inboxId = member.inboxId, + lastActivityTime = conversation.lastActivityNs, + ) + } + } + } + } + } + } + } + + // Sort by most recent activity + val sortedContacts = + contactsMap.values + .sortedByDescending { it.lastActivityTime } + .take(20) // Limit to 20 recent contacts + + _recentContacts.value = sortedContacts + } catch (e: Exception) { + // Silently fail - just show empty recent contacts + _recentContacts.value = emptyList() + } + } + } + sealed class UiState { object Unknown : UiState() diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt deleted file mode 100644 index c892f418f..000000000 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.xmtp.android.example.conversation - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import kotlinx.coroutines.launch -import org.xmtp.android.example.R -import org.xmtp.android.example.databinding.BottomSheetNewGroupBinding -import java.util.regex.Pattern - -class NewGroupBottomSheet : BottomSheetDialogFragment() { - private val viewModel: NewConversationViewModel by viewModels() - private var _binding: BottomSheetNewGroupBinding? = null - private val addresses: MutableList = mutableListOf() - private val binding get() = _binding!! - - companion object { - const val TAG = "NewGroupBottomSheet" - - private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$") - - fun newInstance(): NewGroupBottomSheet = NewGroupBottomSheet() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = BottomSheetNewGroupBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect(::ensureUiState) - } - } - - binding.addressInput1.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput1.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - addresses.add(input.toString()) - binding.addressInput2.visibility = VISIBLE - } - } - - binding.addressInput2.addTextChangedListener { - if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener - val input = binding.addressInput2.text.trim() - val matcher = ADDRESS_PATTERN.matcher(input) - if (matcher.matches()) { - addresses.add(input.toString()) - viewModel.createGroup(addresses) - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun ensureUiState(uiState: NewConversationViewModel.UiState) { - when (uiState) { - is NewConversationViewModel.UiState.Error -> { - binding.addressInput1.isEnabled = true - binding.addressInput2.isEnabled = true - binding.progress.visibility = View.GONE - showError(uiState.message) - } - - NewConversationViewModel.UiState.Loading -> { - binding.addressInput1.isEnabled = false - binding.addressInput2.isEnabled = false - binding.progress.visibility = VISIBLE - } - - is NewConversationViewModel.UiState.Success -> { - startActivity( - ConversationDetailActivity.intent( - requireContext(), - topic = uiState.conversation.topic, - peerAddress = uiState.conversation.id, - ), - ) - dismiss() - } - - NewConversationViewModel.UiState.Unknown -> Unit - } - } - - private fun showError(message: String) { - val error = message.ifBlank { resources.getString(R.string.error) } - Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() - } -} diff --git a/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt new file mode 100644 index 000000000..930fc3c37 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt @@ -0,0 +1,416 @@ +package org.xmtp.android.example.conversation + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xmtp.android.example.ClientManager +import org.xmtp.android.example.ui.theme.XMTPTheme +import org.xmtp.android.library.libxmtp.IdentityKind +import org.xmtp.android.library.libxmtp.InboxState +import org.xmtp.android.library.libxmtp.PublicIdentity +import kotlin.math.abs + +class UserProfileActivity : ComponentActivity() { + companion object { + private const val EXTRA_WALLET_ADDRESS = "EXTRA_WALLET_ADDRESS" + private const val EXTRA_INBOX_ID = "EXTRA_INBOX_ID" + + fun intent( + context: Context, + walletAddress: String, + inboxId: String? = null, + ): Intent = + Intent(context, UserProfileActivity::class.java).apply { + putExtra(EXTRA_WALLET_ADDRESS, walletAddress) + putExtra(EXTRA_INBOX_ID, inboxId) + } + } + + private val avatarColors = listOf( + Color(0xFFFC4F37), + Color(0xFF5856D6), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFF007AFF), + Color(0xFFAF52DE), + Color(0xFF00C7BE), + Color(0xFFFF2D55), + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val walletAddress = intent.getStringExtra(EXTRA_WALLET_ADDRESS) ?: run { + finish() + return + } + val inboxId = intent.getStringExtra(EXTRA_INBOX_ID) + + setContent { + XMTPTheme { + var isLoading by remember { mutableStateOf(false) } + var inboxState by remember { mutableStateOf(null) } + var isStartingConversation by remember { mutableStateOf(false) } + + // Load inbox state if we have an inbox ID + LaunchedEffect(inboxId) { + if (inboxId != null) { + isLoading = true + try { + val inboxStates = withContext(Dispatchers.IO) { + ClientManager.client.inboxStatesForInboxIds( + refreshFromNetwork = true, + inboxIds = listOf(inboxId), + ) + } + inboxState = inboxStates.firstOrNull() + } catch (e: Exception) { + // Silently fail - we still have basic info + } finally { + isLoading = false + } + } + } + + UserProfileScreen( + walletAddress = walletAddress, + inboxId = inboxId, + inboxState = inboxState, + isLoading = isLoading || isStartingConversation, + avatarColors = avatarColors, + onBackClick = { finish() }, + onCopyAddress = { copyToClipboard("Wallet Address", walletAddress) }, + onCopyInboxId = { + inboxId?.let { id -> copyToClipboard("Inbox ID", id) } + }, + onSendMessage = { + isStartingConversation = true + startConversation(walletAddress) + }, + ) + } + } + } + + private fun copyToClipboard(label: String, text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + Toast.makeText(this, "$label copied to clipboard", Toast.LENGTH_SHORT).show() + } + + private fun startConversation(walletAddress: String) { + lifecycleScope.launch { + try { + val conversation = withContext(Dispatchers.IO) { + val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, walletAddress) + ClientManager.client.conversations.findOrCreateDmWithIdentity(publicIdentity) + } + + startActivity( + ConversationDetailActivity.intent( + this@UserProfileActivity, + topic = conversation.topic, + peerAddress = conversation.id, + ), + ) + finish() + } catch (e: Exception) { + Toast.makeText( + this@UserProfileActivity, + e.localizedMessage ?: "Error", + Toast.LENGTH_SHORT, + ).show() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserProfileScreen( + walletAddress: String, + inboxId: String?, + inboxState: InboxState?, + isLoading: Boolean, + avatarColors: List, + onBackClick: () -> Unit, + onCopyAddress: () -> Unit, + onCopyInboxId: () -> Unit, + onSendMessage: () -> Unit, +) { + val avatarText = walletAddress + .removePrefix("0x") + .take(2) + .uppercase() + + val colorIndex = abs((inboxId ?: walletAddress).hashCode()) % avatarColors.size + val avatarColor = avatarColors[colorIndex] + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Profile") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + // Avatar + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = avatarText, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Abbreviated address + Text( + text = walletAddress.truncatedAddress(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Wallet Address Card + InfoCard( + title = "Wallet Address", + value = walletAddress, + onCopy = onCopyAddress, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Inbox ID Card + InfoCard( + title = "Inbox ID", + value = inboxId ?: "Unknown", + onCopy = if (inboxId != null) onCopyInboxId else null, + ) + + // Linked Identities + inboxState?.let { state -> + if (state.identities.size > 1) { + Spacer(modifier = Modifier.height(24.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Linked Identities", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + state.identities.forEach { identity -> + Text( + text = identity.identifier, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Send Message Button + Button( + onClick = onSendMessage, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + enabled = !isLoading, + shape = RoundedCornerShape(12.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.Message, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Send Message", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +@Composable +private fun InfoCard( + title: String, + value: String, + onCopy: (() -> Unit)?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (onCopy != null) { + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +private fun String.truncatedAddress(): String { + if (length >= 10) { + val start = 6 + val end = length - 4 + return replaceRange(start, end, "...") + } + return this +} diff --git a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt index 921f65f96..6324ad794 100644 --- a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt +++ b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt @@ -34,7 +34,7 @@ fun Flow.flowWhileShared( fun stateFlow( scope: CoroutineScope, initialValue: T, - producer: (subscriptionCount: StateFlow) -> Flow, + producer: suspend (subscriptionCount: StateFlow) -> Flow, ): StateFlow { val state = MutableStateFlow(initialValue) scope.launch(Dispatchers.IO) { diff --git a/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt index dceb91444..90d1e22e7 100644 --- a/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt +++ b/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt @@ -1,9 +1,10 @@ package org.xmtp.android.example.extension fun String.truncatedAddress(): String { - if (length > 6) { + // Need at least 10 characters to meaningfully truncate (show first 6 + "..." + last 4) + if (length >= 10) { val start = 6 - val end = lastIndex - 3 + val end = length - 4 return replaceRange(start, end, "...") } return this diff --git a/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt deleted file mode 100644 index d10ee3c2f..000000000 --- a/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.xmtp.android.example.logs - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.ListView -import android.widget.TextView -import android.widget.Toast -import androidx.core.content.FileProvider -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.xmtp.android.example.R -import java.io.File - -class LogViewerBottomSheet : BottomSheetDialogFragment() { - companion object { - const val TAG = "LogViewerBottomSheet" - - fun newInstance(): LogViewerBottomSheet = LogViewerBottomSheet() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? = inflater.inflate(R.layout.bottom_sheet_log_viewer, container, false) - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - - val logsList = view.findViewById(R.id.logs_list) - val emptyText = view.findViewById(R.id.empty_text) - - // Get log files from the app's files directory - val logDirectory = File(requireContext().filesDir, "xmtp_logs") - val logFiles = if (logDirectory.exists()) logDirectory.listFiles() else null - - if (logFiles.isNullOrEmpty()) { - logsList.visibility = View.GONE - emptyText.visibility = View.VISIBLE - } else { - logsList.visibility = View.VISIBLE - emptyText.visibility = View.GONE - - // Sort files by last modified date (newest first) - val sortedFiles = logFiles.sortedByDescending { it.lastModified() } - - // Create adapter with file names and sizes - val fileItems = - sortedFiles.map { - "${it.name} (${formatFileSize(it.length())})" - } - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, fileItems) - logsList.adapter = adapter - - // Handle click on log file - logsList.setOnItemClickListener { _, _, position, _ -> - shareLogFile(sortedFiles[position]) - } - } - } - - private fun formatFileSize(size: Long): String = - when { - size < 1024 -> "$size B" - size < 1024 * 1024 -> "${size / 1024} KB" - else -> "${size / (1024 * 1024)} MB" - } - - private fun shareLogFile(file: File) { - try { - val uri = - FileProvider.getUriForFile( - requireContext(), - "${requireContext().packageName}.fileprovider", - file, - ) - - val shareIntent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, uri) - type = "text/plain" - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - startActivity(Intent.createChooser(shareIntent, getString(R.string.share_log))) - } catch (e: Exception) { - Toast.makeText(context, "Error sharing log: ${e.message}", Toast.LENGTH_SHORT).show() - } - } -} diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt b/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt deleted file mode 100644 index dfef5857c..000000000 --- a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.xmtp.android.example.message - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.conversation.ConversationDetailViewModel -import org.xmtp.android.example.databinding.ListItemMessageBinding - -class MessageAdapter : RecyclerView.Adapter() { - init { - setHasStableIds(true) - } - - private val listItems = mutableListOf() - - fun setData(newItems: List) { - listItems.clear() - listItems.addAll(newItems) - notifyDataSetChanged() - } - - fun addItem(item: ConversationDetailViewModel.MessageListItem) { - listItems.add(0, item) - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - ConversationDetailViewModel.MessageListItem.ITEM_TYPE_MESSAGE -> { - val binding = ListItemMessageBinding.inflate(inflater, parent, false) - MessageViewHolder(binding) - } - else -> throw IllegalArgumentException("Unsupported view type $viewType") - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - val item = listItems[position] - when (holder) { - is MessageViewHolder -> { - holder.bind(item as ConversationDetailViewModel.MessageListItem.Message) - } - else -> throw IllegalArgumentException("Unsupported view holder") - } - } - - override fun getItemViewType(position: Int) = listItems[position].itemType - - override fun getItemCount() = listItems.size - - override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong() -} diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt deleted file mode 100644 index 4f2bee4da..000000000 --- a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.xmtp.android.example.message - -import android.annotation.SuppressLint -import android.graphics.Color -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID -import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.UNSET -import androidx.recyclerview.widget.RecyclerView -import org.xmtp.android.example.ClientManager -import org.xmtp.android.example.R -import org.xmtp.android.example.conversation.ConversationDetailViewModel -import org.xmtp.android.example.databinding.ListItemMessageBinding -import org.xmtp.android.example.extension.margins -import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated -import java.text.SimpleDateFormat -import java.util.Locale - -class MessageViewHolder( - private val binding: ListItemMessageBinding, -) : RecyclerView.ViewHolder(binding.root) { - private val marginLarge = binding.root.resources.getDimensionPixelSize(R.dimen.message_margin) - private val marginSmall = binding.root.resources.getDimensionPixelSize(R.dimen.padding) - private val backgroundMe = Color.LTGRAY - private val backgroundPeer = - binding.root.resources.getColor(R.color.teal_700, binding.root.context.theme) - - @SuppressLint("SetTextI18n") - fun bind(item: ConversationDetailViewModel.MessageListItem.Message) { - val isFromMe = - ClientManager.client.inboxId == item.message.senderInboxId - val params = binding.messageContainer.layoutParams as ConstraintLayout.LayoutParams - if (isFromMe) { - params.rightToRight = PARENT_ID - params.leftToLeft = UNSET - binding.messageRow.margins(left = marginLarge, right = marginSmall) - binding.messageContainer.setCardBackgroundColor(backgroundMe) - binding.messageBody.setTextColor(Color.BLACK) - } else { - params.leftToLeft = PARENT_ID - params.rightToRight = UNSET - binding.messageRow.margins(right = marginLarge, left = marginSmall) - binding.messageContainer.setCardBackgroundColor(backgroundPeer) - binding.messageBody.setTextColor(Color.WHITE) - } - binding.messageContainer.layoutParams = params - if (item.message.content() is String) { - binding.messageBody.text = item.message.body - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - binding.messageDate.text = sdf.format(item.message.sentAt) - } else if (item.message.content() is GroupUpdated) { - val changes = item.message.content() as? GroupUpdated - binding.messageBody.text = - "Membership Changed ${ - changes?.addedInboxesList?.mapNotNull { it.inboxId } - }" - } - } -} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt new file mode 100644 index 000000000..5579a1763 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt @@ -0,0 +1,104 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.AvatarColors +import org.xmtp.android.example.ui.theme.XMTPTheme +import kotlin.math.abs + +@Composable +fun Avatar( + name: String, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + showOnlineIndicator: Boolean = false, +) { + val initials = getInitials(name) + val backgroundColor = getAvatarColor(name) + + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center, + ) { + // Main avatar circle + Box( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + color = Color.White, + fontSize = (size.value * 0.35f).sp, + fontWeight = FontWeight.SemiBold, + ) + } + + // Online indicator + if (showOnlineIndicator) { + Box( + modifier = + Modifier + .size(size * 0.25f) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(Color.White), + ) { + Box( + modifier = + Modifier + .size(size * 0.2f) + .align(Alignment.Center) + .clip(CircleShape) + .background(Color(0xFF34C759)), + ) + } + } + } +} + +private fun getInitials(name: String): String { + val cleaned = name.removePrefix("0x").trim() + return if (cleaned.length >= 2) { + cleaned.take(2).uppercase() + } else { + cleaned.uppercase() + } +} + +private fun getAvatarColor(name: String): Color { + val hash = abs(name.hashCode()) + return AvatarColors[hash % AvatarColors.size] +} + +@Preview +@Composable +private fun AvatarPreview() { + XMTPTheme { + Avatar(name = "0x1234567890abcdef") + } +} + +@Preview +@Composable +private fun AvatarWithOnlinePreview() { + XMTPTheme { + Avatar(name = "John Doe", showOnlineIndicator = true) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt new file mode 100644 index 000000000..9ba3053f2 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt @@ -0,0 +1,231 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +data class RecentContactData( + val address: String, + val lastActivityTimeNs: Long, +) + +private val avatarColors = listOf( + Color(0xFFFC4F37), // XMTP Red + Color(0xFF5856D6), // Purple + Color(0xFF34C759), // Green + Color(0xFFFF9500), // Orange + Color(0xFF007AFF), // Blue + Color(0xFFAF52DE), // Magenta + Color(0xFF00C7BE), // Teal + Color(0xFFFF2D55), // Pink +) + +@Composable +fun ContactRow( + contact: RecentContactData, + modifier: Modifier = Modifier, + isGroupMode: Boolean = false, + isSelected: Boolean = false, + onClick: () -> Unit = {}, + onAddClick: () -> Unit = {}, +) { + val avatarText = contact.address + .removePrefix("0x") + .take(2) + .uppercase() + + val colorIndex = abs(contact.address.hashCode()) % avatarColors.size + val avatarColor = avatarColors[colorIndex] + + val displayAddress = contact.address.truncatedAddress() + val lastActivity = formatLastActivity(contact.lastActivityTimeNs) + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = avatarText, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Contact info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = displayAddress, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = lastActivity, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Group mode controls + if (isGroupMode) { + if (isSelected) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp), + ) + } + } else { + IconButton(onClick = onAddClick) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add to group", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +private fun String.truncatedAddress(): String { + if (length >= 10) { + val start = 6 + val end = length - 4 + return replaceRange(start, end, "...") + } + return this +} + +private fun formatLastActivity(timestampNs: Long): String { + val timestampMs = timestampNs / 1_000_000 + val messageDate = Date(timestampMs) + val now = Calendar.getInstance() + val messageCalendar = Calendar.getInstance().apply { time = messageDate } + + return when { + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> { + "Last messaged today" + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> { + "Last messaged yesterday" + } + now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> { + val daysDiff = now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) + "Last messaged $daysDiff days ago" + } + else -> { + val formattedDate = SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate) + "Last messaged $formattedDate" + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ContactRowPreview() { + XMTPTheme { + ContactRow( + contact = RecentContactData( + address = "0x1234567890abcdef1234567890abcdef12345678", + lastActivityTimeNs = System.currentTimeMillis() * 1_000_000, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ContactRowGroupModePreview() { + XMTPTheme { + ContactRow( + contact = RecentContactData( + address = "0xabcdef1234567890abcdef1234567890abcdef12", + lastActivityTimeNs = (System.currentTimeMillis() - 86400000) * 1_000_000, // yesterday + ), + isGroupMode = true, + isSelected = false, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ContactRowSelectedPreview() { + XMTPTheme { + ContactRow( + contact = RecentContactData( + address = "0x9876543210fedcba9876543210fedcba98765432", + lastActivityTimeNs = (System.currentTimeMillis() - 172800000) * 1_000_000, // 2 days ago + ), + isGroupMode = true, + isSelected = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt new file mode 100644 index 000000000..83bfdd4a5 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt @@ -0,0 +1,202 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun ConversationRow( + name: String, + lastMessage: String, + timestamp: String, + modifier: Modifier = Modifier, + isGroup: Boolean = false, + memberCount: Int? = null, + unreadCount: Int = 0, + onClick: () -> Unit = {}, +) { + Surface( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + Box( + modifier = Modifier.size(52.dp), + contentAlignment = Alignment.Center, + ) { + Avatar(name = name, size = 52.dp) + + // Group/DM indicator badge + Box( + modifier = + Modifier + .size(20.dp) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(if (isGroup) Color(0xFF34C759) else Color(0xFF007AFF)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isGroup) Icons.Default.Group else Icons.Default.Person, + contentDescription = if (isGroup) "Group" else "Direct Message", + modifier = Modifier.size(12.dp), + tint = Color.White, + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Content + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (unreadCount > 0) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = + if (unreadCount > 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (isGroup && memberCount != null) { + Text( + text = "$memberCount members", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = lastMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (unreadCount > 0) FontWeight.Medium else FontWeight.Normal, + ) + } + + if (unreadCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun DMConversationPreview() { + XMTPTheme { + ConversationRow( + name = "0x1234...5678", + lastMessage = "Hey, how are you doing?", + timestamp = "10:30 AM", + isGroup = false, + ) + } +} + +@Preview +@Composable +private fun GroupConversationPreview() { + XMTPTheme { + ConversationRow( + name = "XMTP Dev Team", + lastMessage = "Alice: Let's ship this feature!", + timestamp = "Yesterday", + isGroup = true, + memberCount = 5, + ) + } +} + +@Preview +@Composable +private fun UnreadConversationPreview() { + XMTPTheme { + ConversationRow( + name = "Bob", + lastMessage = "Check out this new update!", + timestamp = "2:45 PM", + isGroup = false, + unreadCount = 3, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt new file mode 100644 index 000000000..891925d94 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt @@ -0,0 +1,157 @@ +package org.xmtp.android.example.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class EditPreviewData( + val messageText: String, + val imageThumbnail: Bitmap? = null, +) + +@Composable +fun EditPreviewBar( + editData: EditPreviewData, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Edit icon with accent bar + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(3.dp) + .height(40.dp) + .background( + MaterialTheme.colorScheme.tertiary, + RoundedCornerShape(2.dp), + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Editing", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Edit content + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = "Editing", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = editData.messageText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Image thumbnail if present + editData.imageThumbnail?.let { bitmap -> + Spacer(modifier = Modifier.width(8.dp)) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Image preview", + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } + + // Close button + IconButton( + onClick = onClose, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel edit", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditPreviewBarPreview() { + XMTPTheme { + EditPreviewBar( + editData = EditPreviewData( + messageText = "This is my original message that I want to edit", + ), + onClose = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EditPreviewBarPhotoPreview() { + XMTPTheme { + EditPreviewBar( + editData = EditPreviewData( + messageText = "Photo", + imageThumbnail = null, // In real usage, this would be a Bitmap + ), + onClose = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt new file mode 100644 index 000000000..363227240 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt @@ -0,0 +1,169 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +val defaultEmojis = listOf( + // Quick reactions row + "\uD83D\uDC4D", // 👍 + "\u2764\uFE0F", // ❤️ + "\uD83D\uDE02", // 😂 + "\uD83D\uDE2E", // 😮 + "\uD83D\uDE22", // 😢 + "\uD83D\uDE4F", // 🙏 + // Smileys & Emotion + "\uD83D\uDE00", "\uD83D\uDE03", "\uD83D\uDE04", "\uD83D\uDE01", "\uD83D\uDE06", + "\uD83D\uDE05", "\uD83E\uDD23", "\uD83D\uDE0A", "\uD83D\uDE07", "\uD83D\uDE42", + "\uD83D\uDE43", "\uD83D\uDE09", "\uD83D\uDE0C", "\uD83D\uDE0D", "\uD83E\uDD70", + "\uD83D\uDE18", "\uD83D\uDE17", "\uD83D\uDE19", "\uD83D\uDE1A", "\uD83D\uDE0B", + "\uD83D\uDE1B", "\uD83D\uDE1C", "\uD83E\uDD2A", "\uD83D\uDE1D", "\uD83E\uDD11", + "\uD83E\uDD17", "\uD83E\uDD2D", "\uD83E\uDD2B", "\uD83E\uDD14", "\uD83E\uDD10", + "\uD83E\uDD28", "\uD83D\uDE10", "\uD83D\uDE11", "\uD83D\uDE36", "\uD83D\uDE0F", + "\uD83D\uDE12", "\uD83D\uDE44", "\uD83D\uDE2C", "\uD83E\uDD25", "\uD83D\uDE14", + "\uD83D\uDE2A", "\uD83E\uDD24", "\uD83D\uDE34", "\uD83D\uDE37", "\uD83E\uDD12", + "\uD83E\uDD15", "\uD83E\uDD22", "\uD83E\uDD2E", "\uD83E\uDD27", "\uD83E\uDD75", + "\uD83E\uDD76", "\uD83E\uDD74", "\uD83D\uDE35", "\uD83E\uDD2F", "\uD83E\uDD20", + "\uD83E\uDD73", "\uD83D\uDE0E", "\uD83E\uDD13", "\uD83E\uDDD0", "\uD83D\uDE15", + "\uD83D\uDE1F", "\uD83D\uDE41", "\u2639\uFE0F", "\uD83D\uDE2E", "\uD83D\uDE2F", + "\uD83D\uDE32", "\uD83D\uDE33", "\uD83E\uDD7A", "\uD83D\uDE26", "\uD83D\uDE27", + "\uD83D\uDE28", "\uD83D\uDE30", "\uD83D\uDE25", "\uD83D\uDE2D", "\uD83D\uDE31", + "\uD83D\uDE16", "\uD83D\uDE23", "\uD83D\uDE1E", "\uD83D\uDE13", "\uD83D\uDE29", + "\uD83D\uDE2B", "\uD83E\uDD71", "\uD83D\uDE24", "\uD83D\uDE21", "\uD83D\uDE20", + "\uD83E\uDD2C", "\uD83D\uDE08", "\uD83D\uDC7F", "\uD83D\uDC80", "\u2620\uFE0F", + "\uD83D\uDCA9", "\uD83E\uDD21", "\uD83D\uDC79", "\uD83D\uDC7A", "\uD83D\uDC7B", + "\uD83D\uDC7D", "\uD83D\uDC7E", "\uD83E\uDD16", + // Gestures + "\uD83D\uDC4E", "\uD83D\uDC4A", "\u270A", "\uD83E\uDD1B", "\uD83E\uDD1C", + "\uD83D\uDC4F", "\uD83D\uDE4C", "\uD83D\uDC50", "\uD83E\uDD32", "\uD83E\uDD1D", + "\u270D\uFE0F", "\uD83D\uDC85", "\uD83E\uDD33", "\uD83D\uDCAA", + // Hearts + "\uD83E\uDDE1", "\uD83D\uDC9B", "\uD83D\uDC9A", "\uD83D\uDC99", "\uD83D\uDC9C", + "\uD83E\uDD0E", "\uD83D\uDDA4", "\uD83E\uDD0D", "\uD83D\uDC94", "\u2763\uFE0F", + "\uD83D\uDC95", "\uD83D\uDC9E", "\uD83D\uDC93", "\uD83D\uDC97", "\uD83D\uDC96", + "\uD83D\uDC98", "\uD83D\uDC9D", "\uD83D\uDC9F", + // Common objects + "\uD83D\uDD25", "\u2B50", "\uD83C\uDF1F", "\u2728", "\uD83C\uDF88", "\uD83C\uDF89", + "\uD83C\uDF8A", "\uD83C\uDF81", "\uD83C\uDF80", "\uD83D\uDCAF", "\uD83D\uDCA2", + "\uD83D\uDCA5", "\uD83D\uDCAB", "\uD83D\uDCA6", "\uD83D\uDCA8", "\uD83D\uDCA3", + "\uD83D\uDCAC", + // Symbols + "\u2714\uFE0F", "\u274C", "\u274E", "\u2753", "\u2757", "\u26A0\uFE0F", + "\uD83D\uDEAB", "\u2705", "\uD83D\uDC4C", +) + +@Composable +fun EmojiPicker( + onEmojiSelected: (String) -> Unit, + modifier: Modifier = Modifier, + emojis: List = defaultEmojis, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(8), + modifier = modifier + .fillMaxWidth() + .height(250.dp), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(emojis) { emoji -> + EmojiItem( + emoji = emoji, + onClick = { onEmojiSelected(emoji) }, + ) + } + } +} + +@Composable +fun QuickReactionPicker( + onEmojiSelected: (String) -> Unit, + modifier: Modifier = Modifier, + reactions: List = defaultEmojis.take(6), +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 2.dp, + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + reactions.forEach { emoji -> + EmojiItem( + emoji = emoji, + onClick = { onEmojiSelected(emoji) }, + modifier = Modifier.size(40.dp), + ) + } + } + } +} + +@Composable +private fun EmojiItem( + emoji: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .size(40.dp) + .clickable(onClick = onClick), + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Text( + text = emoji, + fontSize = 24.sp, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmojiPickerPreview() { + XMTPTheme { + EmojiPicker( + onEmojiSelected = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun QuickReactionPickerPreview() { + XMTPTheme { + QuickReactionPicker( + onEmojiSelected = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt new file mode 100644 index 000000000..b53590a86 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt @@ -0,0 +1,82 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.tooling.preview.Preview +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownSelector( + label: String, + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, + optionLabel: (T) -> String, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = optionLabel(selectedOption), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(optionLabel(option)) }, + onClick = { + onOptionSelected(option) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DropdownSelectorPreview() { + XMTPTheme { + val options = listOf("Dev", "Production", "Local") + var selected by remember { mutableStateOf(options[0]) } + + DropdownSelector( + label = "Environment", + options = options, + selectedOption = selected, + onOptionSelected = { selected = it }, + optionLabel = { it }, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt b/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt new file mode 100644 index 000000000..600aa412b --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt @@ -0,0 +1,74 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun LoadingButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(56.dp), + enabled = enabled && !isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = if (isLoading) "Loading..." else text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoadingButtonPreview() { + XMTPTheme { + LoadingButton( + text = "Generate Wallet", + onClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoadingButtonLoadingPreview() { + XMTPTheme { + LoadingButton( + text = "Generate Wallet", + onClick = {}, + isLoading = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt new file mode 100644 index 000000000..a77a255e6 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt @@ -0,0 +1,296 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme +import kotlin.math.abs + +enum class MemberPermissionLevel { + SUPER_ADMIN, + ADMIN, + MEMBER, +} + +data class MemberRowData( + val inboxId: String, + val displayAddress: String?, + val permissionLevel: MemberPermissionLevel, + val isCurrentUser: Boolean, +) + +private val avatarColors = listOf( + Color(0xFFFC4F37), // XMTP Red + Color(0xFF5856D6), // Purple + Color(0xFF34C759), // Green + Color(0xFFFF9500), // Orange + Color(0xFF007AFF), // Blue + Color(0xFFAF52DE), // Magenta + Color(0xFF00C7BE), // Teal + Color(0xFFFF2D55), // Pink +) + +@Composable +fun MemberRow( + member: MemberRowData, + canManageMembers: Boolean, + modifier: Modifier = Modifier, + onMemberClick: () -> Unit = {}, + onPromoteToAdmin: () -> Unit = {}, + onDemoteFromAdmin: () -> Unit = {}, + onRemoveMember: () -> Unit = {}, +) { + var showMenu by remember { mutableStateOf(false) } + + val avatarText = member.displayAddress + ?.removePrefix("0x") + ?.take(2) + ?.uppercase() + ?: member.inboxId.take(2).uppercase() + + val colorIndex = abs(member.inboxId.hashCode()) % avatarColors.size + val avatarColor = avatarColors[colorIndex] + + val displayName = if (member.isCurrentUser) { + "${member.displayAddress?.truncatedAddress() ?: member.inboxId.truncatedAddress()} (You)" + } else { + member.displayAddress?.truncatedAddress() ?: member.inboxId.truncatedAddress() + } + + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onMemberClick), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = avatarText, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Member info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + + // Role badge + when (member.permissionLevel) { + MemberPermissionLevel.SUPER_ADMIN -> { + RoleBadge( + text = "Super Admin", + color = MaterialTheme.colorScheme.primary, + ) + } + MemberPermissionLevel.ADMIN -> { + RoleBadge( + text = "Admin", + color = Color(0xFF5856D6), + ) + } + MemberPermissionLevel.MEMBER -> { + // No badge for regular members + } + } + } + + Text( + text = "Inbox: ${member.inboxId.take(8)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Menu button + if (canManageMembers && !member.isCurrentUser) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Member options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + when (member.permissionLevel) { + MemberPermissionLevel.MEMBER -> { + DropdownMenuItem( + text = { Text("Promote to Admin") }, + onClick = { + showMenu = false + onPromoteToAdmin() + }, + ) + } + MemberPermissionLevel.ADMIN -> { + DropdownMenuItem( + text = { Text("Remove Admin") }, + onClick = { + showMenu = false + onDemoteFromAdmin() + }, + ) + } + MemberPermissionLevel.SUPER_ADMIN -> { + // Can't modify super admin + } + } + if (member.permissionLevel != MemberPermissionLevel.SUPER_ADMIN) { + DropdownMenuItem( + text = { Text("Remove from Group") }, + onClick = { + showMenu = false + onRemoveMember() + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun RoleBadge( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.15f), + modifier = modifier, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +private fun String.truncatedAddress(): String { + if (length >= 10) { + val start = 6 + val end = length - 4 + return replaceRange(start, end, "...") + } + return this +} + +@Preview(showBackground = true) +@Composable +private fun MemberRowPreview() { + XMTPTheme { + MemberRow( + member = MemberRowData( + inboxId = "abc123def456", + displayAddress = "0x1234567890abcdef1234567890abcdef12345678", + permissionLevel = MemberPermissionLevel.MEMBER, + isCurrentUser = false, + ), + canManageMembers = true, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MemberRowAdminPreview() { + XMTPTheme { + MemberRow( + member = MemberRowData( + inboxId = "admin123456", + displayAddress = "0xabcdef1234567890abcdef1234567890abcdef12", + permissionLevel = MemberPermissionLevel.ADMIN, + isCurrentUser = false, + ), + canManageMembers = true, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MemberRowCurrentUserPreview() { + XMTPTheme { + MemberRow( + member = MemberRowData( + inboxId = "myinbox12345", + displayAddress = "0x9876543210fedcba9876543210fedcba98765432", + permissionLevel = MemberPermissionLevel.SUPER_ADMIN, + isCurrentUser = true, + ), + canManageMembers = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt new file mode 100644 index 000000000..2391663d8 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt @@ -0,0 +1,552 @@ +package org.xmtp.android.example.ui.components + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.SentMessageBackground +import org.xmtp.android.example.ui.theme.XMTPTheme +import java.util.Locale + +enum class DeliveryStatus { + SENDING, + SENT, + FAILED, +} + +sealed class MessageAttachment { + data class Image( + val data: ByteArray, + val mimeType: String, + ) : MessageAttachment() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Image + return data.contentEquals(other.data) && mimeType == other.mimeType + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + mimeType.hashCode() + return result + } + } + + data class File( + val filename: String, + val size: Int, + val mimeType: String, + ) : MessageAttachment() +} + +@Composable +fun MessageBubble( + message: String, + timestamp: String, + isMe: Boolean, + modifier: Modifier = Modifier, + senderName: String? = null, + senderColor: Color? = null, + deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, + reactions: List>? = null, + replyPreview: String? = null, + replyAuthor: String? = null, + isDeleted: Boolean = false, + attachment: MessageAttachment? = null, + onLongClick: () -> Unit = {}, + onReplyClick: (() -> Unit)? = null, + onAttachmentClick: (() -> Unit)? = null, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = if (isMe) Arrangement.End else Arrangement.Start, + ) { + if (isMe) { + Spacer( + modifier = + Modifier + .weight(1f, fill = false) + .widthIn(min = 60.dp), + ) + } + + Column( + horizontalAlignment = if (isMe) Alignment.End else Alignment.Start, + ) { + // Sender name for group messages + if (senderName != null && !isMe) { + Text( + text = senderName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = senderColor ?: MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, bottom = 2.dp), + ) + } + + // Message bubble + Surface( + shape = RoundedCornerShape(18.dp), + color = if (isMe) SentMessageBackground else MaterialTheme.colorScheme.surfaceVariant, + modifier = + Modifier + .widthIn(max = 280.dp) + .clickable(onClick = onLongClick), + ) { + // Use IntrinsicSize.Max to ensure all children share the maximum width + // This makes the reply bubble at least as wide as the preview + Column( + modifier = + Modifier + .width(IntrinsicSize.Max) + .padding( + horizontal = if (attachment is MessageAttachment.Image) 4.dp else 12.dp, + vertical = if (attachment is MessageAttachment.Image) 4.dp else 8.dp, + ), + ) { + // Reply preview + if (replyPreview != null) { + Surface( + shape = RoundedCornerShape(8.dp), + color = + if (isMe) { + Color.White.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .clickable { onReplyClick?.invoke() }, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(2.dp) + .size(2.dp, 24.dp) + .background( + if (isMe) { + Color.White.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + }, + ), + ) + Spacer(modifier = Modifier.width(6.dp)) + Column { + if (replyAuthor != null) { + Text( + text = replyAuthor, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = + if (isMe) { + Color.White.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + Text( + text = replyPreview, + style = MaterialTheme.typography.bodySmall, + color = + if (isMe) { + Color.White.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + ) + } + } + } + } + + // Image attachment + if (attachment is MessageAttachment.Image) { + val bitmap = remember(attachment.data) { + try { + BitmapFactory.decodeByteArray(attachment.data, 0, attachment.data.size) + } catch (e: Exception) { + null + } + } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Image attachment", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { onAttachmentClick?.invoke() }, + contentScale = ContentScale.FillWidth, + ) + } + } + + // File attachment + if (attachment is MessageAttachment.File) { + FileAttachmentView( + filename = attachment.filename, + size = attachment.size, + isMe = isMe, + onClick = onAttachmentClick, + ) + } + + // Message content (show only if there's text and not just an image) + if (message.isNotEmpty() || (attachment == null && !isDeleted)) { + if (attachment is MessageAttachment.Image && message.isEmpty()) { + // Don't show empty text for image-only messages + } else { + val textPadding = if (attachment != null) { + Modifier.padding( + start = 8.dp, + end = 8.dp, + top = if (attachment is MessageAttachment.Image) 4.dp else 0.dp, + ) + } else { + Modifier + } + Text( + text = if (isDeleted) "This message was deleted" else message, + style = MaterialTheme.typography.bodyLarge, + color = if (isMe) Color.White else MaterialTheme.colorScheme.onSurface, + fontStyle = if (isDeleted) FontStyle.Italic else FontStyle.Normal, + modifier = textPadding.then( + if (replyPreview != null) Modifier.fillMaxWidth() else Modifier, + ), + ) + } + } + + // Timestamp and delivery status + Row( + modifier = + Modifier + .align(Alignment.End) + .padding( + top = 2.dp, + end = if (attachment is MessageAttachment.Image) 8.dp else 0.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = + if (isMe) { + Color.White.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontSize = 11.sp, + ) + + if (isMe) { + Spacer(modifier = Modifier.width(4.dp)) + when (deliveryStatus) { + DeliveryStatus.SENDING -> + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = "Sending", + modifier = Modifier.size(12.dp), + tint = Color.White.copy(alpha = 0.7f), + ) + + DeliveryStatus.SENT -> + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Sent", + modifier = Modifier.size(12.dp), + tint = Color.White.copy(alpha = 0.7f), + ) + + DeliveryStatus.FAILED -> + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Failed", + modifier = Modifier.size(12.dp), + tint = Color.Red, + ) + } + } + } + } + } + + // Reactions + if (!reactions.isNullOrEmpty()) { + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + reactions.take(5).forEach { (emoji, count) -> + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = emoji, fontSize = 14.sp) + if (count > 1) { + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + + if (!isMe) { + Spacer( + modifier = + Modifier + .weight(1f, fill = false) + .widthIn(min = 60.dp), + ) + } + } +} + +@Composable +private fun FileAttachmentView( + filename: String, + size: Int, + isMe: Boolean, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isMe) Color.White.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surface, + modifier = modifier + .fillMaxWidth() + .clickable(enabled = onClick != null) { onClick?.invoke() }, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = "File", + modifier = Modifier.size(32.dp), + tint = if (isMe) Color.White else MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (isMe) Color.White else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatFileSize(size), + style = MaterialTheme.typography.bodySmall, + color = if (isMe) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun formatFileSize(bytes: Int): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> String.format(Locale.getDefault(), "%.1f MB", bytes / (1024.0 * 1024.0)) + } + +@Composable +fun SystemMessage( + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } +} + +@Preview +@Composable +private fun SentMessagePreview() { + XMTPTheme { + MessageBubble( + message = "Hello! How are you doing today?", + timestamp = "10:30 AM", + isMe = true, + ) + } +} + +@Preview +@Composable +private fun ReceivedMessagePreview() { + XMTPTheme { + MessageBubble( + message = "I'm doing great, thanks for asking!", + timestamp = "10:31 AM", + isMe = false, + senderName = "0x1234...5678", + senderColor = Color(0xFF5856D6), + ) + } +} + +@Preview +@Composable +private fun MessageWithReactionsPreview() { + XMTPTheme { + MessageBubble( + message = "This is awesome!", + timestamp = "10:32 AM", + isMe = true, + reactions = listOf("👍" to 3, "❤️" to 2), + ) + } +} + +@Preview +@Composable +private fun ReplyMessagePreview() { + XMTPTheme { + MessageBubble( + message = "Yes, I agree with you!", + timestamp = "10:33 AM", + isMe = false, + replyPreview = "This is the original message that was very long...", + replyAuthor = "0x1234...5678", + ) + } +} + +@Preview +@Composable +private fun DeletedMessagePreview() { + XMTPTheme { + MessageBubble( + message = "", + timestamp = "10:34 AM", + isMe = true, + isDeleted = true, + ) + } +} + +@Preview +@Composable +private fun FileAttachmentPreview() { + XMTPTheme { + MessageBubble( + message = "", + timestamp = "10:35 AM", + isMe = true, + attachment = MessageAttachment.File( + filename = "document.pdf", + size = 1024 * 512, + mimeType = "application/pdf", + ), + ) + } +} + +@Preview +@Composable +private fun ReceivedFileAttachmentPreview() { + XMTPTheme { + MessageBubble( + message = "", + timestamp = "10:36 AM", + isMe = false, + senderName = "0x1234...5678", + attachment = MessageAttachment.File( + filename = "presentation.pptx", + size = 1024 * 1024 * 2, + mimeType = "application/vnd.ms-powerpoint", + ), + ) + } +} + +@Preview +@Composable +private fun SystemMessagePreview() { + XMTPTheme { + SystemMessage(text = "Alice joined the group") + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt new file mode 100644 index 000000000..7800446bd --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt @@ -0,0 +1,305 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun MessageComposer( + value: String, + onValueChange: (String) -> Unit, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, + isSending: Boolean = false, + replyTo: String? = null, + replyAuthor: String? = null, + onClearReply: () -> Unit = {}, + onAttachmentClick: () -> Unit = {}, + onEmojiClick: () -> Unit = {}, +) { + val canSend = value.isNotBlank() && !isSending + + Surface( + modifier = + modifier + .fillMaxWidth() + .imePadding(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column { + // Reply preview + AnimatedVisibility( + visible = replyTo != null, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + if (replyTo != null) { + ReplyPreview( + message = replyTo, + author = replyAuthor, + onDismiss = onClearReply, + ) + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + ) { + // Attachment button + IconButton( + onClick = onAttachmentClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add attachment", + tint = MaterialTheme.colorScheme.primary, + ) + } + + // Text input field + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.Bottom, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = + Modifier + .weight(1f) + .heightIn(min = 40.dp, max = 120.dp) + .padding(horizontal = 12.dp, vertical = 10.dp), + textStyle = + TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + text = "Message", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + } + innerTextField() + } + }, + enabled = !isSending, + ) + + // Emoji button + IconButton( + onClick = onEmojiClick, + modifier = + Modifier + .size(36.dp) + .padding(bottom = 2.dp), + ) { + Icon( + imageVector = Icons.Default.EmojiEmotions, + contentDescription = "Emoji", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Send button + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(enabled = canSend) { onSendClick() }, + contentAlignment = Alignment.Center, + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } +} + +@Composable +private fun ReplyPreview( + message: String, + author: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Left accent bar + Box( + modifier = + Modifier + .width(2.dp) + .height(32.dp) + .background(MaterialTheme.colorScheme.primary), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Replying to ${author ?: "message"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Preview +@Composable +private fun MessageComposerPreview() { + XMTPTheme { + var text by remember { mutableStateOf("") } + MessageComposer( + value = text, + onValueChange = { text = it }, + onSendClick = {}, + ) + } +} + +@Preview +@Composable +private fun MessageComposerWithTextPreview() { + XMTPTheme { + MessageComposer( + value = "Hello, this is a test message!", + onValueChange = {}, + onSendClick = {}, + ) + } +} + +@Preview +@Composable +private fun MessageComposerSendingPreview() { + XMTPTheme { + MessageComposer( + value = "Sending message...", + onValueChange = {}, + onSendClick = {}, + isSending = true, + ) + } +} + +@Preview +@Composable +private fun MessageComposerWithReplyPreview() { + XMTPTheme { + MessageComposer( + value = "", + onValueChange = {}, + onSendClick = {}, + replyTo = "This is the original message content", + replyAuthor = "0x1234...5678", + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt b/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt new file mode 100644 index 000000000..2eba0bae0 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt @@ -0,0 +1,287 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class DrawerState( + val walletAddress: String, + val environment: String, + val isLogsEnabled: Boolean, + val hideDeletedMessages: Boolean, +) + +@Composable +fun NavigationDrawerContent( + drawerState: DrawerState, + onWalletInfoClick: () -> Unit, + onNewConversationClick: () -> Unit, + onNewGroupClick: () -> Unit, + onHideDeletedMessagesToggle: (Boolean) -> Unit, + onViewLogsClick: () -> Unit, + onToggleLogsClick: (Boolean) -> Unit, + onCopyAddressClick: () -> Unit, + onDisconnectClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalDrawerSheet(modifier = modifier) { + // Header + DrawerHeader( + walletAddress = drawerState.walletAddress, + environment = drawerState.environment, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Main actions + DrawerItem( + label = "Wallet Info", + icon = Icons.Default.Info, + onClick = onWalletInfoClick, + ) + + DrawerItem( + label = "New Conversation", + icon = Icons.Default.Add, + onClick = onNewConversationClick, + ) + + DrawerItem( + label = "New Group", + icon = Icons.Default.GroupAdd, + onClick = onNewGroupClick, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Settings section + Text( + text = "Settings", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp), + ) + + DrawerToggleItem( + label = "Hide Deleted Messages", + icon = Icons.Default.VisibilityOff, + checked = drawerState.hideDeletedMessages, + onCheckedChange = onHideDeletedMessagesToggle, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Logs section + Text( + text = "Logs", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp), + ) + + DrawerItem( + label = "View Logs", + icon = Icons.Default.Description, + onClick = onViewLogsClick, + ) + + DrawerToggleItem( + label = "Persistent Logs", + icon = Icons.Default.BugReport, + checked = drawerState.isLogsEnabled, + onCheckedChange = onToggleLogsClick, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Account section + Text( + text = "Account", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp), + ) + + DrawerItem( + label = "Copy Address", + icon = Icons.Default.ContentCopy, + onClick = onCopyAddressClick, + ) + + DrawerItem( + label = "Disconnect Wallet", + icon = Icons.AutoMirrored.Filled.Logout, + onClick = onDisconnectClick, + tint = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +private fun DrawerHeader( + walletAddress: String, + environment: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary) + .statusBarsPadding() + .padding(16.dp), + ) { + Column { + // Avatar + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Profile", + modifier = Modifier.size(36.dp), + tint = Color.White, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Wallet address + Text( + text = walletAddress, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Environment + Text( + text = environment, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun DrawerItem( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + NavigationDrawerItem( + icon = { + Icon( + imageVector = icon, + contentDescription = label, + tint = tint, + ) + }, + label = { + Text( + text = label, + color = if (tint == MaterialTheme.colorScheme.error) tint else MaterialTheme.colorScheme.onSurface, + ) + }, + selected = false, + onClick = onClick, + modifier = modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) +} + +@Composable +private fun DrawerToggleItem( + label: String, + icon: ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + NavigationDrawerItem( + icon = { + Icon( + imageVector = icon, + contentDescription = label, + ) + }, + label = { Text(label) }, + badge = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + }, + selected = false, + onClick = { onCheckedChange(!checked) }, + modifier = modifier.padding(NavigationDrawerItemDefaults.ItemPadding), + ) +} + +@Preview(showBackground = true) +@Composable +private fun NavigationDrawerContentPreview() { + XMTPTheme { + NavigationDrawerContent( + drawerState = DrawerState( + walletAddress = "0x1234567890abcdef1234567890abcdef12345678", + environment = "DEV", + isLogsEnabled = false, + hideDeletedMessages = false, + ), + onWalletInfoClick = {}, + onNewConversationClick = {}, + onNewGroupClick = {}, + onHideDeletedMessagesToggle = {}, + onViewLogsClick = {}, + onToggleLogsClick = {}, + onCopyAddressClick = {}, + onDisconnectClick = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt new file mode 100644 index 000000000..e5add20c6 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt @@ -0,0 +1,160 @@ +package org.xmtp.android.example.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class ReplyPreviewData( + val authorName: String, + val messageText: String, + val imageThumbnail: Bitmap? = null, +) + +@Composable +fun ReplyPreviewBar( + replyData: ReplyPreviewData, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Reply icon with accent bar + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(3.dp) + .height(40.dp) + .background( + MaterialTheme.colorScheme.primary, + RoundedCornerShape(2.dp), + ), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = "Replying to", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Reply content + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = replyData.authorName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = replyData.messageText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Image thumbnail if present + replyData.imageThumbnail?.let { bitmap -> + Spacer(modifier = Modifier.width(8.dp)) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Image preview", + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } + + // Close button + IconButton( + onClick = onClose, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReplyPreviewBarPreview() { + XMTPTheme { + ReplyPreviewBar( + replyData = ReplyPreviewData( + authorName = "0x1234...5678", + messageText = "Hey, did you see the new update?", + ), + onClose = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ReplyPreviewBarPhotoPreview() { + XMTPTheme { + ReplyPreviewBar( + replyData = ReplyPreviewData( + authorName = "Alice", + messageText = "Photo", + imageThumbnail = null, // In real usage, this would be a Bitmap + ), + onClose = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt new file mode 100644 index 000000000..600b5887e --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt @@ -0,0 +1,375 @@ +package org.xmtp.android.example.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class SearchResult( + val id: String, + val senderName: String, + val content: String, + val timestamp: String, + val isDeleted: Boolean = false, +) + +@Composable +fun MessageSearchBar( + searchText: String, + onSearchTextChange: (String) -> Unit, + isSearching: Boolean, + onSearchToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + + AnimatedVisibility( + visible = isSearching, + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Search input field + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + BasicTextField( + value = searchText, + onValueChange = onSearchTextChange, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = + TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (searchText.isEmpty()) { + Text( + text = "Search messages", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + } + innerTextField() + } + }, + ) + + if (searchText.isNotEmpty()) { + IconButton( + onClick = { onSearchTextChange("") }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + TextButton(onClick = { + onSearchTextChange("") + onSearchToggle(false) + }) { + Text("Cancel") + } + } + } + } +} + +@Composable +fun SearchResultsList( + searchText: String, + results: List, + onResultClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (searchText.isEmpty()) { + return + } + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + if (results.isEmpty()) { + // Empty state + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No results found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Try searching for different keywords", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items( + items = results, + key = { it.id }, + ) { result -> + SearchResultRow( + result = result, + searchText = searchText, + onClick = { onResultClick(result.id) }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 72.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } +} + +@Composable +private fun SearchResultRow( + result: SearchResult, + searchText: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + // Avatar + Avatar(name = result.senderName, size = 44.dp) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Sender and timestamp row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = result.senderName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = result.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Highlighted content + Text( + text = highlightSearchTerm(result.content, searchText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun highlightSearchTerm( + text: String, + searchTerm: String, +) = buildAnnotatedString { + if (searchTerm.isEmpty()) { + append(text) + return@buildAnnotatedString + } + + val lowercaseText = text.lowercase() + val lowercaseSearch = searchTerm.lowercase() + var currentIndex = 0 + + while (currentIndex < text.length) { + val matchIndex = lowercaseText.indexOf(lowercaseSearch, currentIndex) + if (matchIndex == -1) { + append(text.substring(currentIndex)) + break + } + + // Add text before match + if (matchIndex > currentIndex) { + append(text.substring(currentIndex, matchIndex)) + } + + // Add highlighted match + withStyle( + style = + SpanStyle( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ), + ) { + append(text.substring(matchIndex, matchIndex + searchTerm.length)) + } + + currentIndex = matchIndex + searchTerm.length + } +} + +@Preview +@Composable +private fun SearchBarPreview() { + XMTPTheme { + var searchText by remember { mutableStateOf("") } + MessageSearchBar( + searchText = searchText, + onSearchTextChange = { searchText = it }, + isSearching = true, + onSearchToggle = {}, + ) + } +} + +@Preview +@Composable +private fun SearchResultsPreview() { + XMTPTheme { + SearchResultsList( + searchText = "hello", + results = + listOf( + SearchResult( + id = "1", + senderName = "0x1234...5678", + content = "Hello! How are you doing today?", + timestamp = "10:30 AM", + ), + SearchResult( + id = "2", + senderName = "Alice", + content = "Just wanted to say hello and check in", + timestamp = "Yesterday", + ), + ), + onResultClick = {}, + ) + } +} + +@Preview +@Composable +private fun EmptySearchResultsPreview() { + XMTPTheme { + SearchResultsList( + searchText = "xyz", + results = emptyList(), + onResultClick = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt b/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt new file mode 100644 index 000000000..b52c0ec2c --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt @@ -0,0 +1,62 @@ +package org.xmtp.android.example.ui.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.xmtp.android.example.ui.theme.XMTPTheme + +@Composable +fun DeleteConfirmationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String = "Delete Message", + message: String = "Are you sure you want to delete this message?", +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + ) { + Text( + text = "Delete", + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = "Cancel") + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun DeleteConfirmationDialogPreview() { + XMTPTheme { + DeleteConfirmationDialog( + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt new file mode 100644 index 000000000..3c3744455 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt @@ -0,0 +1,126 @@ +package org.xmtp.android.example.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import org.xmtp.android.example.connect.ConnectWalletViewModel +import org.xmtp.android.example.ui.components.DrawerState +import org.xmtp.android.example.ui.screens.ChatScreen +import org.xmtp.android.example.ui.screens.ConnectWalletScreen +import org.xmtp.android.example.ui.screens.ConversationItem +import org.xmtp.android.example.ui.screens.MainScreen +import org.xmtp.android.example.ui.screens.MessageItem +import org.xmtp.android.library.XMTPEnvironment +import uniffi.xmtpv3.FfiLogLevel + +sealed class Screen( + val route: String, +) { + object ConnectWallet : Screen("connect") + + object Home : Screen("home") + + object Chat : Screen("chat/{conversationId}") { + fun createRoute(conversationId: String) = "chat/$conversationId" + } +} + +@Composable +fun AppNavigation( + navController: NavHostController = rememberNavController(), + startDestination: String = Screen.Home.route, + modifier: Modifier = Modifier, + // Connect wallet state + connectWalletUiState: ConnectWalletViewModel.ConnectUiState = ConnectWalletViewModel.ConnectUiState.Unknown, + onGenerateWallet: (XMTPEnvironment, FfiLogLevel?) -> Unit = { _, _ -> }, + onConnectSuccess: (String) -> Unit = {}, + // Main screen state + conversations: List = emptyList(), + isLoading: Boolean = false, + drawerState: DrawerState = DrawerState( + walletAddress = "", + environment = "", + isLogsEnabled = false, + hideDeletedMessages = false, + ), + // Main screen drawer actions + onNewConversationClick: () -> Unit = {}, + onSearchClick: () -> Unit = {}, + onWalletInfoClick: () -> Unit = {}, + onNewGroupClick: () -> Unit = {}, + onHideDeletedMessagesToggle: (Boolean) -> Unit = {}, + onViewLogsClick: () -> Unit = {}, + onToggleLogsClick: (Boolean) -> Unit = {}, + onCopyAddressClick: () -> Unit = {}, + onDisconnectClick: () -> Unit = {}, + // Chat screen state + messages: Map> = emptyMap(), + onSendMessage: (String, String) -> Unit = { _, _ -> }, +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + ) { + composable(Screen.ConnectWallet.route) { + ConnectWalletScreen( + uiState = connectWalletUiState, + onGenerateWallet = onGenerateWallet, + onConnectSuccess = { address -> + onConnectSuccess(address) + navController.navigate(Screen.Home.route) { + popUpTo(Screen.ConnectWallet.route) { inclusive = true } + } + }, + ) + } + + composable(Screen.Home.route) { + MainScreen( + conversations = conversations, + drawerState = drawerState, + isLoading = isLoading, + onConversationClick = { conversationId -> + navController.navigate(Screen.Chat.createRoute(conversationId)) + }, + onNewConversationClick = onNewConversationClick, + onSearchClick = onSearchClick, + onWalletInfoClick = onWalletInfoClick, + onNewGroupClick = onNewGroupClick, + onHideDeletedMessagesToggle = onHideDeletedMessagesToggle, + onViewLogsClick = onViewLogsClick, + onToggleLogsClick = onToggleLogsClick, + onCopyAddressClick = onCopyAddressClick, + onDisconnectClick = onDisconnectClick, + ) + } + + composable( + route = Screen.Chat.route, + arguments = + listOf( + navArgument("conversationId") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val conversationId = backStackEntry.arguments?.getString("conversationId") ?: "" + val conversation = conversations.find { it.id == conversationId } + val conversationMessages = messages[conversationId] ?: emptyList() + + ChatScreen( + conversationName = conversation?.name ?: "Conversation", + messages = conversationMessages, + isGroup = conversation?.isGroup ?: false, + memberCount = conversation?.memberCount, + onBackClick = { navController.popBackStack() }, + onSendMessage = { message -> + onSendMessage(conversationId, message) + }, + ) + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt new file mode 100644 index 000000000..6c4e13660 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt @@ -0,0 +1,451 @@ +package org.xmtp.android.example.ui.screens + +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class AttachmentPreviewItem( + val uri: Uri, + val filename: String, + val fileSize: String, + val mimeType: String, + val thumbnail: Bitmap? = null, + val isImage: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +fun AttachmentPreviewScreen( + attachments: List, + caption: String = "", + selectedIndex: Int = 0, + onCaptionChange: (String) -> Unit = {}, + onSelectedIndexChange: (Int) -> Unit = {}, + onClose: () -> Unit = {}, + onSend: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState( + initialPage = selectedIndex, + pageCount = { attachments.size }, + ) + + // Sync pager with selected index from parent + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage != selectedIndex) { + onSelectedIndexChange(pagerState.currentPage) + } + } + + LaunchedEffect(selectedIndex) { + if (pagerState.currentPage != selectedIndex) { + pagerState.animateScrollToPage(selectedIndex) + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Black, + topBar = { + TopAppBar( + title = { + if (attachments.size > 1) { + Text( + text = "${selectedIndex + 1} of ${attachments.size}", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = "Preview", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + ) + } + }, + actions = { + IconButton(onClick = onSend) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Black.copy(alpha = 0.5f), + ), + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + // Main content area + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + if (attachments.size == 1) { + // Single attachment + AttachmentContent( + attachment = attachments.first(), + modifier = Modifier.fillMaxSize(), + ) + } else { + // Multiple attachments - pager + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + AttachmentContent( + attachment = attachments[page], + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + // Caption input + CaptionInput( + caption = caption, + onCaptionChange = onCaptionChange, + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.5f)) + .padding(12.dp), + ) + + // Thumbnail strip for multiple attachments + if (attachments.size > 1) { + ThumbnailStrip( + attachments = attachments, + selectedIndex = selectedIndex, + onThumbnailClick = { index -> onSelectedIndexChange(index) }, + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.5f)) + .padding(8.dp), + ) + } + } + } +} + +@Composable +private fun AttachmentContent( + attachment: AttachmentPreviewItem, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + if (attachment.isImage && attachment.thumbnail != null) { + Image( + bitmap = attachment.thumbnail.asImageBitmap(), + contentDescription = attachment.filename, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } else { + // File preview + FilePreviewContent( + filename = attachment.filename, + fileSize = attachment.fileSize, + ) + } + } +} + +@Composable +private fun FilePreviewContent( + filename: String, + fileSize: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "File", + tint = Color.White, + modifier = Modifier.size(80.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = filename, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = fileSize, + color = Color.White.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun CaptionInput( + caption: String, + onCaptionChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = caption, + onValueChange = onCaptionChange, + modifier = modifier, + placeholder = { + Text( + text = "Add a caption...", + color = Color.White.copy(alpha = 0.5f), + ) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Color.White, + focusedBorderColor = Color.White, + unfocusedBorderColor = Color.White.copy(alpha = 0.5f), + ), + maxLines = 3, + ) +} + +@Composable +private fun ThumbnailStrip( + attachments: List, + selectedIndex: Int, + onThumbnailClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(attachments) { index, attachment -> + ThumbnailItem( + attachment = attachment, + isSelected = index == selectedIndex, + onClick = { onThumbnailClick(index) }, + ) + } + } +} + +@Composable +private fun ThumbnailItem( + attachment: AttachmentPreviewItem, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(60.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = if (isSelected) 2.dp else 0.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + if (attachment.isImage && attachment.thumbnail != null) { + Image( + bitmap = attachment.thumbnail.asImageBitmap(), + contentDescription = attachment.filename, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "File", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun AttachmentPreviewScreenSingleImagePreview() { + XMTPTheme { + AttachmentPreviewScreen( + attachments = listOf( + AttachmentPreviewItem( + uri = Uri.EMPTY, + filename = "photo.jpg", + fileSize = "2.5 MB", + mimeType = "image/jpeg", + isImage = true, + thumbnail = null, + ), + ), + caption = "", + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun AttachmentPreviewScreenFilePreview() { + XMTPTheme { + AttachmentPreviewScreen( + attachments = listOf( + AttachmentPreviewItem( + uri = Uri.EMPTY, + filename = "document.pdf", + fileSize = "1.2 MB", + mimeType = "application/pdf", + isImage = false, + ), + ), + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun AttachmentPreviewScreenMultiplePreview() { + XMTPTheme { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Simulating multiple attachment preview + Text( + text = "1 of 3", + color = Color.White, + modifier = Modifier.padding(16.dp), + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.DarkGray), + contentAlignment = Alignment.Center, + ) { + FilePreviewContent( + filename = "document.pdf", + fileSize = "1.2 MB", + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(3) { index -> + Box( + modifier = Modifier + .size(60.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.DarkGray) + .border( + width = if (index == 0) 2.dp else 0.dp, + color = if (index == 0) Color.Blue else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "File", + tint = Color.White, + ) + } + } + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt new file mode 100644 index 000000000..bfd5c91c6 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt @@ -0,0 +1,388 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.DeliveryStatus +import org.xmtp.android.example.ui.components.MessageBubble +import org.xmtp.android.example.ui.components.MessageComposer +import org.xmtp.android.example.ui.components.MessageSearchBar +import org.xmtp.android.example.ui.components.SearchResult +import org.xmtp.android.example.ui.components.SearchResultsList +import org.xmtp.android.example.ui.components.SystemMessage +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class MessageItem( + val id: String, + val content: String, + val timestamp: String, + val isMe: Boolean, + val senderName: String? = null, + val senderColor: Color? = null, + val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, + val reactions: List>? = null, + val replyPreview: String? = null, + val replyAuthor: String? = null, + val isDeleted: Boolean = false, + val isSystemMessage: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + conversationName: String, + messages: List, + isLoading: Boolean = false, + isGroup: Boolean = false, + memberCount: Int? = null, + replyingTo: MessageItem? = null, + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onInfoClick: () -> Unit = {}, + onSendMessage: (String) -> Unit = {}, + onAttachmentClick: () -> Unit = {}, + onEmojiClick: () -> Unit = {}, + onCancelReply: () -> Unit = {}, + onMessageLongClick: (String) -> Unit = {}, + onReplyClick: (String) -> Unit = {}, + onScrollToMessage: (String) -> Unit = {}, +) { + val listState = rememberLazyListState() + var messageText by rememberSaveable { mutableStateOf("") } + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchText by rememberSaveable { mutableStateOf("") } + + // Filter messages for search results (exclude deleted and system messages) + val searchResults by remember(searchText, messages) { + derivedStateOf { + if (searchText.isEmpty()) { + emptyList() + } else { + val lowercaseSearch = searchText.lowercase() + messages + .filter { msg -> + !msg.isSystemMessage && + !msg.isDeleted && + msg.content.isNotEmpty() && + msg.content.lowercase().contains(lowercaseSearch) + }.map { msg -> + SearchResult( + id = msg.id, + senderName = msg.senderName ?: "You", + content = msg.content, + timestamp = msg.timestamp, + isDeleted = msg.isDeleted, + ) + } + } + } + } + + // Scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty() && !isSearching) { + listState.animateScrollToItem(messages.size - 1) + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Column { + TopAppBar( + title = { + Column { + Text( + text = conversationName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isGroup && memberCount != null) { + Text( + text = "$memberCount members", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { isSearching = !isSearching }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + if (isGroup) { + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Group Info", + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + + // Search bar + MessageSearchBar( + searchText = searchText, + onSearchTextChange = { searchText = it }, + isSearching = isSearching, + onSearchToggle = { isSearching = it }, + ) + } + }, + bottomBar = { + AnimatedVisibility( + visible = !isSearching, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + MessageComposer( + value = messageText, + onValueChange = { messageText = it }, + onSendClick = { + if (messageText.isNotBlank()) { + onSendMessage(messageText) + messageText = "" + } + }, + replyTo = replyingTo?.content, + replyAuthor = replyingTo?.senderName, + onClearReply = onCancelReply, + onAttachmentClick = onAttachmentClick, + onEmojiClick = onEmojiClick, + ) + } + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } else if (isSearching && searchText.isNotEmpty()) { + // Show search results + SearchResultsList( + searchText = searchText, + results = searchResults, + onResultClick = { messageId -> + // Scroll to message and close search + onScrollToMessage(messageId) + isSearching = false + searchText = "" + + // Find the index of the message and scroll to it + val index = messages.indexOfFirst { it.id == messageId } + if (index >= 0) { + // Using launched effect won't work here, so we handle it via callback + } + }, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(vertical = 8.dp), + reverseLayout = false, + ) { + items( + items = messages, + key = { it.id }, + ) { message -> + if (message.isSystemMessage) { + SystemMessage(text = message.content) + } else { + MessageBubble( + message = message.content, + timestamp = message.timestamp, + isMe = message.isMe, + senderName = message.senderName, + senderColor = message.senderColor, + deliveryStatus = message.deliveryStatus, + reactions = message.reactions, + replyPreview = message.replyPreview, + replyAuthor = message.replyAuthor, + isDeleted = message.isDeleted, + onLongClick = { onMessageLongClick(message.id) }, + onReplyClick = { onReplyClick(message.id) }, + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatScreenPreview() { + XMTPTheme { + ChatScreen( + conversationName = "0x1234...5678", + messages = + listOf( + MessageItem( + id = "1", + content = "Hey! How are you?", + timestamp = "10:30 AM", + isMe = false, + senderName = "0x1234...5678", + ), + MessageItem( + id = "2", + content = "I'm doing great! Just shipped a new feature.", + timestamp = "10:31 AM", + isMe = true, + ), + MessageItem( + id = "3", + content = "That's awesome! Can you show me?", + timestamp = "10:32 AM", + isMe = false, + senderName = "0x1234...5678", + ), + MessageItem( + id = "4", + content = "Sure, let me send you a screenshot!", + timestamp = "10:33 AM", + isMe = true, + reactions = listOf("👍" to 1, "🔥" to 2), + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GroupChatScreenPreview() { + XMTPTheme { + ChatScreen( + conversationName = "XMTP Dev Team", + isGroup = true, + memberCount = 5, + messages = + listOf( + MessageItem( + id = "sys1", + content = "Alice joined the group", + timestamp = "", + isMe = false, + isSystemMessage = true, + ), + MessageItem( + id = "1", + content = "Welcome to the team!", + timestamp = "10:30 AM", + isMe = false, + senderName = "Bob", + senderColor = Color(0xFF5856D6), + ), + MessageItem( + id = "2", + content = "Thanks! Excited to be here.", + timestamp = "10:31 AM", + isMe = true, + ), + MessageItem( + id = "3", + content = "Let's ship some features!", + timestamp = "10:32 AM", + isMe = false, + senderName = "Charlie", + senderColor = Color(0xFFFF9500), + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatWithReplyPreview() { + XMTPTheme { + ChatScreen( + conversationName = "Alice", + messages = + listOf( + MessageItem( + id = "1", + content = "Hey, did you see the new update?", + timestamp = "10:30 AM", + isMe = false, + ), + MessageItem( + id = "2", + content = "Yes! It looks amazing!", + timestamp = "10:31 AM", + isMe = true, + replyPreview = "Hey, did you see the new update?", + replyAuthor = "Alice", + ), + ), + replyingTo = + MessageItem( + id = "1", + content = "Hey, did you see the new update?", + timestamp = "10:30 AM", + isMe = false, + ), + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt new file mode 100644 index 000000000..b25bae707 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt @@ -0,0 +1,217 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.R +import org.xmtp.android.example.connect.ConnectWalletViewModel +import org.xmtp.android.example.ui.components.DropdownSelector +import org.xmtp.android.example.ui.components.LoadingButton +import org.xmtp.android.example.ui.theme.XMTPTheme +import org.xmtp.android.library.XMTPEnvironment +import uniffi.xmtpv3.FfiLogLevel + +data class EnvironmentOption( + val environment: XMTPEnvironment, + val displayName: String, +) + +data class LogLevelOption( + val logLevel: FfiLogLevel?, + val displayName: String, +) + +private val environmentOptions = listOf( + EnvironmentOption(XMTPEnvironment.DEV, "Dev"), + EnvironmentOption(XMTPEnvironment.PRODUCTION, "Production"), + EnvironmentOption(XMTPEnvironment.LOCAL, "Local"), +) + +private val logLevelOptions = listOf( + LogLevelOption(null, "Off"), + LogLevelOption(FfiLogLevel.ERROR, "Error"), + LogLevelOption(FfiLogLevel.WARN, "Warn"), + LogLevelOption(FfiLogLevel.INFO, "Info"), + LogLevelOption(FfiLogLevel.DEBUG, "Debug"), + LogLevelOption(FfiLogLevel.TRACE, "Trace"), +) + +@Composable +fun ConnectWalletScreen( + uiState: ConnectWalletViewModel.ConnectUiState, + onGenerateWallet: (XMTPEnvironment, FfiLogLevel?) -> Unit, + onConnectSuccess: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var selectedEnvironment by remember { mutableStateOf(environmentOptions[0]) } + var selectedLogLevel by remember { mutableStateOf(logLevelOptions[0]) } + + val isLoading = uiState is ConnectWalletViewModel.ConnectUiState.Loading + val errorMessage = (uiState as? ConnectWalletViewModel.ConnectUiState.Error)?.message + + // Handle success navigation + LaunchedEffect(uiState) { + if (uiState is ConnectWalletViewModel.ConnectUiState.Success) { + onConnectSuccess(uiState.address) + } + } + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // XMTP Logo + Image( + painter = painterResource(id = R.drawable.ic_xmtp_white), + contentDescription = "XMTP Logo", + modifier = Modifier.size(120.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Title + Text( + text = "Welcome to XMTP", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Subtitle + Text( + text = "Connect your wallet to start messaging", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // Environment Selector + DropdownSelector( + label = "Environment", + options = environmentOptions, + selectedOption = selectedEnvironment, + onOptionSelected = { selectedEnvironment = it }, + optionLabel = { it.displayName }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Log Level Selector + DropdownSelector( + label = "Log Level", + options = logLevelOptions, + selectedOption = selectedLogLevel, + onOptionSelected = { selectedLogLevel = it }, + optionLabel = { it.displayName }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Generate Wallet Button + LoadingButton( + text = "Generate Wallet", + onClick = { + onGenerateWallet( + selectedEnvironment.environment, + selectedLogLevel.logLevel, + ) + }, + isLoading = isLoading, + modifier = Modifier.fillMaxWidth(), + ) + + // Error Message + if (errorMessage != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(48.dp)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectWalletScreenPreview() { + XMTPTheme { + ConnectWalletScreen( + uiState = ConnectWalletViewModel.ConnectUiState.Unknown, + onGenerateWallet = { _, _ -> }, + onConnectSuccess = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectWalletScreenLoadingPreview() { + XMTPTheme { + ConnectWalletScreen( + uiState = ConnectWalletViewModel.ConnectUiState.Loading, + onGenerateWallet = { _, _ -> }, + onConnectSuccess = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectWalletScreenErrorPreview() { + XMTPTheme { + ConnectWalletScreen( + uiState = ConnectWalletViewModel.ConnectUiState.Error("Failed to connect to network"), + onGenerateWallet = { _, _ -> }, + onConnectSuccess = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt new file mode 100644 index 000000000..f033d8611 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt @@ -0,0 +1,357 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.MemberPermissionLevel +import org.xmtp.android.example.ui.components.MemberRow +import org.xmtp.android.example.ui.components.MemberRowData +import org.xmtp.android.example.ui.theme.XMTPTheme +import kotlin.math.abs + +data class GroupDetails( + val groupId: String, + val groupName: String, + val createdAt: String, + val memberCount: Int, + val currentUserRole: MemberPermissionLevel, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupManagementScreen( + groupDetails: GroupDetails, + members: List, + isLoading: Boolean = false, + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onAddMemberClick: () -> Unit = {}, + onMemberClick: (String) -> Unit = {}, + onPromoteToAdmin: (String) -> Unit = {}, + onDemoteFromAdmin: (String) -> Unit = {}, + onRemoveMember: (String) -> Unit = {}, + onLeaveGroup: () -> Unit = {}, +) { + val canManageMembers = groupDetails.currentUserRole == MemberPermissionLevel.SUPER_ADMIN || + groupDetails.currentUserRole == MemberPermissionLevel.ADMIN + + val avatarColors = listOf( + Color(0xFFFC4F37), + Color(0xFF5856D6), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFF007AFF), + Color(0xFFAF52DE), + Color(0xFF00C7BE), + Color(0xFFFF2D55), + ) + val avatarColor = avatarColors[abs(groupDetails.groupId.hashCode()) % avatarColors.size] + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Group Info") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + // Group Avatar + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = groupDetails.groupName + .removePrefix("0x") + .take(2) + .uppercase(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Group ID + Text( + text = groupDetails.groupId, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Group Details Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Group Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoRow(label = "Created", value = groupDetails.createdAt) + Spacer(modifier = Modifier.height(12.dp)) + InfoRow(label = "Members", value = "${groupDetails.memberCount} members") + Spacer(modifier = Modifier.height(12.dp)) + InfoRow( + label = "Your Role", + value = when (groupDetails.currentUserRole) { + MemberPermissionLevel.SUPER_ADMIN -> "Super Admin" + MemberPermissionLevel.ADMIN -> "Admin" + MemberPermissionLevel.MEMBER -> "Member" + }, + valueColor = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Members Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Members", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + + if (canManageMembers) { + TextButton(onClick = onAddMemberClick) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = "Add member", + modifier = Modifier.size(18.dp), + ) + Text( + text = "Add", + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + members.forEach { member -> + MemberRow( + member = member, + canManageMembers = canManageMembers && !member.isCurrentUser, + onMemberClick = { onMemberClick(member.inboxId) }, + onPromoteToAdmin = { onPromoteToAdmin(member.inboxId) }, + onDemoteFromAdmin = { onDemoteFromAdmin(member.inboxId) }, + onRemoveMember = { onRemoveMember(member.inboxId) }, + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Leave Group Button + OutlinedButton( + onClick = onLeaveGroup, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(52.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = "Leave group", + modifier = Modifier.size(20.dp), + ) + Text( + text = "Leave Group", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + fontWeight = if (valueColor != MaterialTheme.colorScheme.onSurface) FontWeight.Bold else FontWeight.Normal, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GroupManagementScreenPreview() { + XMTPTheme { + GroupManagementScreen( + groupDetails = GroupDetails( + groupId = "0x1234...abcd", + groupName = "XMTP Dev Team", + createdAt = "Jan 15, 2024", + memberCount = 3, + currentUserRole = MemberPermissionLevel.SUPER_ADMIN, + ), + members = listOf( + MemberRowData( + inboxId = "inbox1", + displayAddress = "0x1234...5678", + permissionLevel = MemberPermissionLevel.SUPER_ADMIN, + isCurrentUser = true, + ), + MemberRowData( + inboxId = "inbox2", + displayAddress = "0xabcd...efgh", + permissionLevel = MemberPermissionLevel.ADMIN, + isCurrentUser = false, + ), + MemberRowData( + inboxId = "inbox3", + displayAddress = "0x9876...5432", + permissionLevel = MemberPermissionLevel.MEMBER, + isCurrentUser = false, + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GroupManagementScreenLoadingPreview() { + XMTPTheme { + GroupManagementScreen( + groupDetails = GroupDetails( + groupId = "", + groupName = "", + createdAt = "", + memberCount = 0, + currentUserRole = MemberPermissionLevel.MEMBER, + ), + members = emptyList(), + isLoading = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt new file mode 100644 index 000000000..8049f92a8 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt @@ -0,0 +1,223 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.ConversationRow +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class ConversationItem( + val id: String, + val name: String, + val lastMessage: String, + val timestamp: String, + val isGroup: Boolean = false, + val memberCount: Int? = null, + val unreadCount: Int = 0, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + conversations: List, + isLoading: Boolean = false, + modifier: Modifier = Modifier, + onConversationClick: (String) -> Unit = {}, + onNewConversationClick: () -> Unit = {}, + onSearchClick: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Messages", + style = MaterialTheme.typography.headlineMedium, + ) + }, + actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNewConversationClick, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Conversation", + ) + } + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + + conversations.isEmpty() -> { + EmptyConversationsView( + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> { + ConversationsList( + conversations = conversations, + onConversationClick = onConversationClick, + ) + } + } + } + } +} + +@Composable +private fun ConversationsList( + conversations: List, + onConversationClick: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = conversations, + key = { it.id }, + ) { conversation -> + ConversationRow( + name = conversation.name, + lastMessage = conversation.lastMessage, + timestamp = conversation.timestamp, + isGroup = conversation.isGroup, + memberCount = conversation.memberCount, + unreadCount = conversation.unreadCount, + onClick = { onConversationClick(conversation.id) }, + ) + Divider( + modifier = Modifier.padding(start = 80.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } +} + +@Composable +private fun EmptyConversationsView(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "No Conversations Yet", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Start a new conversation using the + button", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeScreenPreview() { + XMTPTheme { + HomeScreen( + conversations = + listOf( + ConversationItem( + id = "1", + name = "0x1234...5678", + lastMessage = "Hey, how are you?", + timestamp = "10:30 AM", + isGroup = false, + ), + ConversationItem( + id = "2", + name = "XMTP Dev Team", + lastMessage = "Alice: Let's ship this!", + timestamp = "Yesterday", + isGroup = true, + memberCount = 5, + unreadCount = 3, + ), + ConversationItem( + id = "3", + name = "Bob", + lastMessage = "Check out this update!", + timestamp = "2:45 PM", + isGroup = false, + unreadCount = 1, + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyHomeScreenPreview() { + XMTPTheme { + HomeScreen(conversations = emptyList()) + } +} + +@Preview(showBackground = true) +@Composable +private fun LoadingHomeScreenPreview() { + XMTPTheme { + HomeScreen( + conversations = emptyList(), + isLoading = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt new file mode 100644 index 000000000..86d733c35 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt @@ -0,0 +1,283 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.xmtp.android.example.ui.components.ConversationRow +import org.xmtp.android.example.ui.components.DrawerState +import org.xmtp.android.example.ui.components.NavigationDrawerContent +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + conversations: List, + drawerState: DrawerState, + isLoading: Boolean = false, + modifier: Modifier = Modifier, + onConversationClick: (String) -> Unit = {}, + onNewConversationClick: () -> Unit = {}, + onSearchClick: () -> Unit = {}, + onWalletInfoClick: () -> Unit = {}, + onNewGroupClick: () -> Unit = {}, + onHideDeletedMessagesToggle: (Boolean) -> Unit = {}, + onViewLogsClick: () -> Unit = {}, + onToggleLogsClick: (Boolean) -> Unit = {}, + onCopyAddressClick: () -> Unit = {}, + onDisconnectClick: () -> Unit = {}, +) { + val drawerStateCompose = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + ModalNavigationDrawer( + drawerState = drawerStateCompose, + drawerContent = { + NavigationDrawerContent( + drawerState = drawerState, + onWalletInfoClick = { + scope.launch { drawerStateCompose.close() } + onWalletInfoClick() + }, + onNewConversationClick = { + scope.launch { drawerStateCompose.close() } + onNewConversationClick() + }, + onNewGroupClick = { + scope.launch { drawerStateCompose.close() } + onNewGroupClick() + }, + onHideDeletedMessagesToggle = onHideDeletedMessagesToggle, + onViewLogsClick = { + scope.launch { drawerStateCompose.close() } + onViewLogsClick() + }, + onToggleLogsClick = onToggleLogsClick, + onCopyAddressClick = { + scope.launch { drawerStateCompose.close() } + onCopyAddressClick() + }, + onDisconnectClick = { + scope.launch { drawerStateCompose.close() } + onDisconnectClick() + }, + ) + }, + modifier = modifier, + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Messages", + style = MaterialTheme.typography.headlineMedium, + ) + }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerStateCompose.open() } }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Open menu", + ) + } + }, + actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNewConversationClick, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Conversation", + ) + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + + conversations.isEmpty() -> { + EmptyStateView( + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> { + ConversationsList( + conversations = conversations, + onConversationClick = onConversationClick, + ) + } + } + } + } + } +} + +@Composable +private fun ConversationsList( + conversations: List, + onConversationClick: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = conversations, + key = { it.id }, + ) { conversation -> + ConversationRow( + name = conversation.name, + lastMessage = conversation.lastMessage, + timestamp = conversation.timestamp, + isGroup = conversation.isGroup, + memberCount = conversation.memberCount, + unreadCount = conversation.unreadCount, + onClick = { onConversationClick(conversation.id) }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 80.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } +} + +@Composable +private fun EmptyStateView(modifier: Modifier = Modifier) { + androidx.compose.foundation.layout.Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "No Conversations Yet", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Start a new conversation using the + button or from the menu", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MainScreenPreview() { + XMTPTheme { + MainScreen( + conversations = listOf( + ConversationItem( + id = "1", + name = "0x1234...5678", + lastMessage = "Hey, how are you?", + timestamp = "10:30 AM", + isGroup = false, + ), + ConversationItem( + id = "2", + name = "XMTP Dev Team", + lastMessage = "Alice: Let's ship this!", + timestamp = "Yesterday", + isGroup = true, + memberCount = 5, + unreadCount = 3, + ), + ), + drawerState = DrawerState( + walletAddress = "0x1234567890abcdef1234567890abcdef12345678", + environment = "DEV", + isLogsEnabled = false, + hideDeletedMessages = false, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MainScreenEmptyPreview() { + XMTPTheme { + MainScreen( + conversations = emptyList(), + drawerState = DrawerState( + walletAddress = "0x1234567890abcdef1234567890abcdef12345678", + environment = "DEV", + isLogsEnabled = false, + hideDeletedMessages = false, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MainScreenLoadingPreview() { + XMTPTheme { + MainScreen( + conversations = emptyList(), + drawerState = DrawerState( + walletAddress = "0x1234567890abcdef1234567890abcdef12345678", + environment = "DEV", + isLogsEnabled = false, + hideDeletedMessages = false, + ), + isLoading = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt new file mode 100644 index 000000000..ec12c2fd8 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt @@ -0,0 +1,393 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.ContactRow +import org.xmtp.android.example.ui.components.RecentContactData +import org.xmtp.android.example.ui.theme.XMTPTheme + +enum class ConversationType { + DM, + GROUP, +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun NewConversationScreen( + addressInput: String, + groupName: String = "", + selectedMembers: List = emptyList(), + recentContacts: List = emptyList(), + conversationType: ConversationType = ConversationType.DM, + isLoading: Boolean = false, + canCreate: Boolean = false, + modifier: Modifier = Modifier, + onAddressChange: (String) -> Unit = {}, + onGroupNameChange: (String) -> Unit = {}, + onConversationTypeChange: (ConversationType) -> Unit = {}, + onAddMember: () -> Unit = {}, + onRemoveMember: (String) -> Unit = {}, + onRecentContactClick: (String) -> Unit = {}, + onClose: () -> Unit = {}, + onCreate: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("New Message") }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + bottomBar = { + Button( + onClick = onCreate, + enabled = canCreate && !isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(52.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text( + text = when (conversationType) { + ConversationType.DM -> "Start Conversation" + ConversationType.GROUP -> "Create Group" + }, + ) + } + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // Conversation type selector + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + SegmentedButton( + selected = conversationType == ConversationType.DM, + onClick = { onConversationTypeChange(ConversationType.DM) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "DM", + modifier = Modifier.size(18.dp), + ) + }, + ) { + Text("Direct Message") + } + SegmentedButton( + selected = conversationType == ConversationType.GROUP, + onClick = { onConversationTypeChange(ConversationType.GROUP) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + icon = { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Group", + modifier = Modifier.size(18.dp), + ) + }, + ) { + Text("Group Chat") + } + } + + // Group name input (only for groups) + if (conversationType == ConversationType.GROUP) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + OutlinedTextField( + value = groupName, + onValueChange = onGroupNameChange, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { Text("Group name (optional)") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Group", + ) + }, + singleLine = true, + ) + } + + // Selected members chips + if (selectedMembers.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + selectedMembers.forEach { member -> + InputChip( + selected = false, + onClick = { onRemoveMember(member) }, + label = { + Text( + text = if (member.length > 12) { + member.take(6) + "..." + member.takeLast(4) + } else { + member + }, + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + ) + }, + modifier = Modifier.padding(end = 8.dp, bottom = 4.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Address input + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = addressInput, + onValueChange = onAddressChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Enter wallet address") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Address", + ) + }, + trailingIcon = { + if (addressInput.isNotEmpty()) { + IconButton(onClick = { onAddressChange("") }) { + Icon( + imageVector = if (conversationType == ConversationType.GROUP) { + Icons.Default.Add + } else { + Icons.Default.Clear + }, + contentDescription = if (conversationType == ConversationType.GROUP) { + "Add member" + } else { + "Clear" + }, + ) + } + } + }, + singleLine = true, + ) + + if (conversationType == ConversationType.GROUP && addressInput.isNotEmpty()) { + IconButton(onClick = onAddMember) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add member", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + + // Helper text + Text( + text = "Enter a valid Ethereum address (0x...)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + + if (conversationType == ConversationType.GROUP && selectedMembers.isNotEmpty()) { + Text( + text = "${selectedMembers.size} member(s) added", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + + // Recent contacts section + if (recentContacts.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "RECENT CONTACTS", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + + recentContacts.forEach { contact -> + ContactRow( + contact = contact, + isGroupMode = conversationType == ConversationType.GROUP, + isSelected = selectedMembers.contains(contact.address), + onClick = { onRecentContactClick(contact.address) }, + onAddClick = { onRecentContactClick(contact.address) }, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NewConversationScreenDMPreview() { + XMTPTheme { + NewConversationScreen( + addressInput = "0x1234567890abcdef", + conversationType = ConversationType.DM, + recentContacts = listOf( + RecentContactData( + address = "0xabcd1234efgh5678", + lastActivityTimeNs = System.currentTimeMillis() * 1_000_000, + ), + RecentContactData( + address = "0x9876543210fedcba", + lastActivityTimeNs = (System.currentTimeMillis() - 3600000) * 1_000_000, + ), + ), + canCreate = true, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun NewConversationScreenGroupPreview() { + XMTPTheme { + NewConversationScreen( + addressInput = "", + groupName = "XMTP Dev Team", + conversationType = ConversationType.GROUP, + selectedMembers = listOf( + "0x1234567890abcdef1234567890abcdef12345678", + "0xabcdef1234567890abcdef1234567890abcdef12", + ), + recentContacts = listOf( + RecentContactData( + address = "0xabcd1234efgh5678", + lastActivityTimeNs = System.currentTimeMillis() * 1_000_000, + ), + ), + canCreate = true, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun NewConversationScreenLoadingPreview() { + XMTPTheme { + NewConversationScreen( + addressInput = "0x1234567890abcdef", + conversationType = ConversationType.DM, + isLoading = true, + canCreate = true, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt new file mode 100644 index 000000000..bce72ca62 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt @@ -0,0 +1,251 @@ +package org.xmtp.android.example.ui.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.Avatar +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + walletAddress: String, + inboxId: String, + installationId: String, + modifier: Modifier = Modifier, + onLogout: () -> Unit = {}, + onShowQR: () -> Unit = {}, +) { + val context = LocalContext.current + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Profile", + style = MaterialTheme.typography.headlineMedium, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Avatar + Avatar( + name = walletAddress, + size = 100.dp, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Wallet Address + Text( + text = abbreviateAddress(walletAddress), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Info Cards + InfoCard( + title = "Wallet Address", + value = walletAddress, + onCopy = { copyToClipboard(context, "Wallet Address", walletAddress) }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + InfoCard( + title = "Inbox ID", + value = inboxId, + onCopy = { copyToClipboard(context, "Inbox ID", inboxId) }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + InfoCard( + title = "Installation ID", + value = installationId, + onCopy = { copyToClipboard(context, "Installation ID", installationId) }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // QR Code Button + Button( + onClick = onShowQR, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Icon( + imageVector = Icons.Default.QrCode, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Show QR Code", + modifier = Modifier.padding(start = 8.dp), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Logout Button + Button( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Logout", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +@Composable +private fun InfoCard( + title: String, + value: String, + onCopy: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +private fun abbreviateAddress(address: String): String = + if (address.length > 12) { + "${address.take(6)}...${address.takeLast(4)}" + } else { + address + } + +private fun copyToClipboard( + context: Context, + label: String, + text: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "$label copied to clipboard", Toast.LENGTH_SHORT).show() +} + +@Preview(showBackground = true) +@Composable +private fun ProfileScreenPreview() { + XMTPTheme { + ProfileScreen( + walletAddress = "0x1234567890123456789012345678901234567890", + inboxId = "abcdef1234567890abcdef1234567890", + installationId = "installation-id-12345", + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt new file mode 100644 index 000000000..504dd4d71 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt @@ -0,0 +1,313 @@ +package org.xmtp.android.example.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + hideDeletedMessages: Boolean = false, + notificationsEnabled: Boolean = true, + darkModeEnabled: Boolean = false, + modifier: Modifier = Modifier, + onHideDeletedMessagesChange: (Boolean) -> Unit = {}, + onNotificationsChange: (Boolean) -> Unit = {}, + onDarkModeChange: (Boolean) -> Unit = {}, + onPrivacyClick: () -> Unit = {}, + onStorageClick: () -> Unit = {}, + onViewLogsClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, + onClearDataClick: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // Appearance Section + SectionTitle(text = "Appearance") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.DarkMode, + title = "Dark Mode", + subtitle = "Use dark theme", + isChecked = darkModeEnabled, + onCheckedChange = onDarkModeChange, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Messages Section + SectionTitle(text = "Messages") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.DeleteForever, + title = "Hide Deleted Messages", + subtitle = "Don't show deleted messages in conversations", + isChecked = hideDeletedMessages, + onCheckedChange = onHideDeletedMessagesChange, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Notifications Section + SectionTitle(text = "Notifications") + + SettingsCard { + SettingsToggleRow( + icon = Icons.Default.Notifications, + title = "Push Notifications", + subtitle = "Receive notifications for new messages", + isChecked = notificationsEnabled, + onCheckedChange = onNotificationsChange, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // General Section + SectionTitle(text = "General") + + SettingsCard { + SettingsNavigationRow( + icon = Icons.Default.Security, + title = "Privacy & Security", + onClick = onPrivacyClick, + ) + SettingsNavigationRow( + icon = Icons.Default.Storage, + title = "Storage", + onClick = onStorageClick, + ) + SettingsNavigationRow( + icon = Icons.Default.BugReport, + title = "View Logs", + onClick = onViewLogsClick, + ) + SettingsNavigationRow( + icon = Icons.Default.Info, + title = "About", + onClick = onAboutClick, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Danger Zone + SectionTitle(text = "Danger Zone", color = MaterialTheme.colorScheme.error) + + SettingsCard( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ) { + SettingsNavigationRow( + icon = Icons.Default.DeleteForever, + title = "Clear All Data", + iconTint = MaterialTheme.colorScheme.error, + onClick = onClearDataClick, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Version info + Text( + text = "XMTP Example App v1.0.0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +@Composable +private fun SectionTitle( + text: String, + color: Color = MaterialTheme.colorScheme.primary, +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = color, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp), + ) +} + +@Composable +private fun SettingsCard( + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + ) { + Column { + content() + } + } +} + +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!isChecked) } + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + ) + } +} + +@Composable +private fun SettingsNavigationRow( + icon: ImageVector, + title: String, + iconTint: Color = MaterialTheme.colorScheme.primary, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingsScreenPreview() { + XMTPTheme { + SettingsScreen() + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingsScreenDarkPreview() { + XMTPTheme(darkTheme = true) { + SettingsScreen(darkModeEnabled = true) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt new file mode 100644 index 000000000..3e0d25058 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt @@ -0,0 +1,219 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Gif +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +sealed class AttachmentOption { + object Camera : AttachmentOption() + object Gallery : AttachmentOption() + object File : AttachmentOption() + object Gif : AttachmentOption() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentPickerSheet( + onDismiss: () -> Unit, + onOptionSelected: (AttachmentOption) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "Send Attachment", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AttachmentOptionItem( + icon = Icons.Default.CameraAlt, + label = "Camera", + backgroundColor = Color(0xFFE3F2FD), + iconTint = Color(0xFF1976D2), + onClick = { + onOptionSelected(AttachmentOption.Camera) + onDismiss() + }, + ) + + AttachmentOptionItem( + icon = Icons.Default.Image, + label = "Gallery", + backgroundColor = Color(0xFFE8F5E9), + iconTint = Color(0xFF388E3C), + onClick = { + onOptionSelected(AttachmentOption.Gallery) + onDismiss() + }, + ) + + AttachmentOptionItem( + icon = Icons.Default.AttachFile, + label = "File", + backgroundColor = Color(0xFFFFF3E0), + iconTint = Color(0xFFF57C00), + onClick = { + onOptionSelected(AttachmentOption.File) + onDismiss() + }, + ) + + AttachmentOptionItem( + icon = Icons.Default.Gif, + label = "GIF", + backgroundColor = Color(0xFFFCE4EC), + iconTint = Color(0xFFC2185B), + onClick = { + onOptionSelected(AttachmentOption.Gif) + onDismiss() + }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun AttachmentOptionItem( + icon: ImageVector, + label: String, + backgroundColor: Color, + iconTint: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = iconTint, + modifier = Modifier.size(28.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AttachmentPickerContentPreview() { + XMTPTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + ) { + Text( + text = "Send Attachment", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AttachmentOptionItem( + icon = Icons.Default.CameraAlt, + label = "Camera", + backgroundColor = Color(0xFFE3F2FD), + iconTint = Color(0xFF1976D2), + onClick = {}, + ) + + AttachmentOptionItem( + icon = Icons.Default.Image, + label = "Gallery", + backgroundColor = Color(0xFFE8F5E9), + iconTint = Color(0xFF388E3C), + onClick = {}, + ) + + AttachmentOptionItem( + icon = Icons.Default.AttachFile, + label = "File", + backgroundColor = Color(0xFFFFF3E0), + iconTint = Color(0xFFF57C00), + onClick = {}, + ) + + AttachmentOptionItem( + icon = Icons.Default.Gif, + label = "GIF", + backgroundColor = Color(0xFFFCE4EC), + iconTint = Color(0xFFC2185B), + onClick = {}, + ) + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt new file mode 100644 index 000000000..90df229a4 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt @@ -0,0 +1,198 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme +import java.io.File + +data class LogFileInfo( + val file: File, + val name: String, + val size: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogViewerSheet( + logFiles: List, + onDismiss: () -> Unit, + onShareFile: (File) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + // Title + Text( + text = "Log Files", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (logFiles.isEmpty()) { + // Empty state + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No log files found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enable logs to start collecting debug information", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } else { + // Log files list + LazyColumn( + modifier = Modifier.height(300.dp), + ) { + items(logFiles) { logFile -> + LogFileRow( + logFileInfo = logFile, + onClick = { onShareFile(logFile.file) }, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun LogFileRow( + logFileInfo: LogFileInfo, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = logFileInfo.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = logFileInfo.size, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share log file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +fun formatFileSize(size: Long): String = + when { + size < 1024 -> "$size B" + size < 1024 * 1024 -> "${size / 1024} KB" + else -> "${size / (1024 * 1024)} MB" + } + +@Preview(showBackground = true) +@Composable +private fun LogViewerSheetEmptyPreview() { + XMTPTheme { + LogViewerSheet( + logFiles = emptyList(), + onDismiss = {}, + onShareFile = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LogViewerSheetWithFilesPreview() { + XMTPTheme { + LogViewerSheet( + logFiles = listOf( + LogFileInfo(File(""), "xmtp_2024_01_15_10_30.log", "256 KB"), + LogFileInfo(File(""), "xmtp_2024_01_14_15_45.log", "512 KB"), + LogFileInfo(File(""), "xmtp_2024_01_13_09_20.log", "1.2 MB"), + ), + onDismiss = {}, + onShareFile = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt new file mode 100644 index 000000000..fd09d5cae --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt @@ -0,0 +1,285 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class MessageOptionsState( + val messageId: String, + val canEdit: Boolean, + val canDelete: Boolean, + val currentReaction: String? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageOptionsSheet( + state: MessageOptionsState, + onDismiss: () -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onReaction: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Quick reactions bar + QuickReactionsBar( + currentReaction = state.currentReaction, + onReactionClick = { emoji -> + onReaction(emoji) + onDismiss() + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Actions card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(12.dp), + ) { + Column { + // Reply button + ActionItem( + icon = Icons.AutoMirrored.Filled.Reply, + label = "Reply", + onClick = { + onReply() + onDismiss() + }, + ) + + // Edit button (conditional) + if (state.canEdit) { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ActionItem( + icon = Icons.Default.Edit, + label = "Edit", + onClick = { + onEdit() + onDismiss() + }, + ) + } + + // Delete button (conditional) + if (state.canDelete) { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ActionItem( + icon = Icons.Default.Delete, + label = "Delete", + tint = MaterialTheme.colorScheme.error, + onClick = { + onDelete() + onDismiss() + }, + ) + } + } + } + } + } +} + +@Composable +private fun QuickReactionsBar( + currentReaction: String?, + onReactionClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val reactions = listOf("👍", "❤️", "😂", "😮", "😢", "😡") + + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(24.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + reactions.forEach { emoji -> + ReactionButton( + emoji = emoji, + isSelected = emoji == currentReaction, + onClick = { onReactionClick(emoji) }, + ) + } + } + } +} + +@Composable +private fun ReactionButton( + emoji: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .size(44.dp) + .clip(RoundedCornerShape(22.dp)) + .clickable(onClick = onClick), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(22.dp), + ) { + Text( + text = emoji, + fontSize = 24.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(6.dp), + ) + } +} + +@Composable +private fun ActionItem( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = tint, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = tint, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MessageOptionsSheetPreview() { + XMTPTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Preview content (simulating sheet content) + QuickReactionsBar( + currentReaction = "👍", + onReactionClick = {}, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(12.dp), + ) { + Column { + ActionItem( + icon = Icons.AutoMirrored.Filled.Reply, + label = "Reply", + onClick = {}, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ActionItem( + icon = Icons.Default.Edit, + label = "Edit", + onClick = {}, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ActionItem( + icon = Icons.Default.Delete, + label = "Delete", + tint = MaterialTheme.colorScheme.error, + onClick = {}, + ) + } + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt new file mode 100644 index 000000000..6321925e6 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt @@ -0,0 +1,207 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.ContactRow +import org.xmtp.android.example.ui.components.RecentContactData +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewConversationSheet( + addressInput: String, + recentContacts: List = emptyList(), + isLoading: Boolean = false, + canCreate: Boolean = false, + onDismiss: () -> Unit, + onAddressChange: (String) -> Unit, + onRecentContactClick: (String) -> Unit, + onCreate: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "New Conversation", + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Address input + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + OutlinedTextField( + value = addressInput, + onValueChange = onAddressChange, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { Text("Enter wallet address") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Address", + ) + }, + trailingIcon = { + if (addressInput.isNotEmpty()) { + IconButton(onClick = { onAddressChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + ) + } + } + }, + singleLine = true, + ) + } + + Text( + text = "Enter a valid Ethereum address (0x...)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + + // Recent contacts + if (recentContacts.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "RECENT CONTACTS", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + recentContacts.take(5).forEach { contact -> + ContactRow( + contact = contact, + onClick = { onRecentContactClick(contact.address) }, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Create button + Button( + onClick = { + onCreate() + onDismiss() + }, + enabled = canCreate && !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Start Conversation") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NewConversationSheetContentPreview() { + XMTPTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "New Conversation", + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + OutlinedTextField( + value = "0x1234567890abcdef", + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { Text("Enter wallet address") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Address", + ) + }, + singleLine = true, + ) + } + + Text( + text = "Enter a valid Ethereum address (0x...)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = {}, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text("Start Conversation") + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt new file mode 100644 index 000000000..5ed5c1ac1 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt @@ -0,0 +1,328 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.components.ContactRow +import org.xmtp.android.example.ui.components.RecentContactData +import org.xmtp.android.example.ui.theme.XMTPTheme + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun NewGroupSheet( + groupName: String, + addressInput: String, + selectedMembers: List = emptyList(), + recentContacts: List = emptyList(), + isLoading: Boolean = false, + canCreate: Boolean = false, + onDismiss: () -> Unit, + onGroupNameChange: (String) -> Unit, + onAddressChange: (String) -> Unit, + onAddMember: () -> Unit, + onRemoveMember: (String) -> Unit, + onRecentContactClick: (String) -> Unit, + onCreate: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "New Group", + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Group name input + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + OutlinedTextField( + value = groupName, + onValueChange = onGroupNameChange, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { Text("Group name (optional)") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Group", + ) + }, + singleLine = true, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Selected members chips + if (selectedMembers.isNotEmpty()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedMembers.forEach { member -> + InputChip( + selected = false, + onClick = { onRemoveMember(member) }, + label = { + Text( + text = if (member.length > 12) { + member.take(6) + "..." + member.takeLast(4) + } else { + member + }, + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + ) + }, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "${selectedMembers.size} member(s) added", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Address input + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = addressInput, + onValueChange = onAddressChange, + modifier = Modifier + .weight(1f) + .padding(4.dp), + placeholder = { Text("Add member address") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Address", + ) + }, + singleLine = true, + ) + + if (addressInput.isNotEmpty()) { + IconButton(onClick = onAddMember) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add member", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + + Text( + text = "Enter a valid Ethereum address (0x...)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + + // Recent contacts + if (recentContacts.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "RECENT CONTACTS", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + recentContacts.take(5).forEach { contact -> + ContactRow( + contact = contact, + isGroupMode = true, + isSelected = selectedMembers.contains(contact.address), + onClick = { onRecentContactClick(contact.address) }, + onAddClick = { onRecentContactClick(contact.address) }, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Create button + Button( + onClick = { + onCreate() + onDismiss() + }, + enabled = canCreate && !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Create Group") + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview(showBackground = true) +@Composable +private fun NewGroupSheetContentPreview() { + XMTPTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "New Group", + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + OutlinedTextField( + value = "XMTP Dev Team", + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + placeholder = { Text("Group name (optional)") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Group", + ) + }, + singleLine = true, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + InputChip( + selected = false, + onClick = {}, + label = { Text("0x1234...5678") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + ) + }, + ) + InputChip( + selected = false, + onClick = {}, + label = { Text("0xabcd...efgh") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + ) + }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "2 member(s) added", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = {}, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text("Create Group") + } + } + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt new file mode 100644 index 000000000..311e93e73 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt @@ -0,0 +1,226 @@ +package org.xmtp.android.example.ui.sheets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xmtp.android.example.ui.theme.XMTPTheme + +data class WalletInfo( + val walletAddress: String, + val inboxId: String, + val installationId: String, + val environment: String, + val libXmtpVersion: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletInfoSheet( + walletInfo: WalletInfo, + isLogsEnabled: Boolean, + onDismiss: () -> Unit, + onCopyValue: (label: String, value: String) -> Unit, + onLogsToggled: (Boolean) -> Unit, + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + ) { + // Title + Text( + text = "Wallet Info", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Wallet Address + InfoRow( + label = "Wallet Address", + value = walletInfo.walletAddress, + onCopy = { onCopyValue("Wallet Address", walletInfo.walletAddress) }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Inbox ID + InfoRow( + label = "Inbox ID", + value = walletInfo.inboxId, + onCopy = { onCopyValue("Inbox ID", walletInfo.inboxId) }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Installation ID + InfoRow( + label = "Installation ID", + value = walletInfo.installationId, + onCopy = { onCopyValue("Installation ID", walletInfo.installationId) }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Environment + InfoRow( + label = "Environment", + value = walletInfo.environment, + onCopy = { onCopyValue("Environment", walletInfo.environment) }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // LibXMTP Version + InfoRow( + label = "LibXMTP Version", + value = walletInfo.libXmtpVersion, + onCopy = { onCopyValue("LibXMTP Version", walletInfo.libXmtpVersion) }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Logs Toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Enable Logs", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Persist debug logs to file", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isLogsEnabled, + onCheckedChange = onLogsToggled, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Disconnect Button + Button( + onClick = onDisconnect, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Disconnect Wallet") + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy $label", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WalletInfoSheetPreview() { + XMTPTheme { + WalletInfoSheet( + walletInfo = WalletInfo( + walletAddress = "0x1234567890abcdef1234567890abcdef12345678", + inboxId = "inbox-id-12345", + installationId = "installation-id-67890", + environment = "DEV", + libXmtpVersion = "1.0.0", + ), + isLogsEnabled = false, + onDismiss = {}, + onCopyValue = { _, _ -> }, + onLogsToggled = {}, + onDisconnect = {}, + ) + } +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt new file mode 100644 index 000000000..266fee98b --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt @@ -0,0 +1,40 @@ +package org.xmtp.android.example.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +// XMTP Brand Colors +val XMTPBlue = Color(0xFF0052FF) +val XMTPBlueLight = Color(0xFF4D8AFF) +val XMTPBlueDark = Color(0xFF0041CC) + +// Message bubble colors +val SentMessageBackground = Color(0xFF0052FF) +val ReceivedMessageBackground = Color(0xFFE8E8ED) +val ReceivedMessageBackgroundDark = Color(0xFF2C2C2E) + +// Status colors +val OnlineGreen = Color(0xFF34C759) +val WarningOrange = Color(0xFFFF9500) +val ErrorRed = Color(0xFFFF3B30) + +// Avatar colors for consistent sender identification +val AvatarColors = + listOf( + Color(0xFFFC4F37), + Color(0xFF5856D6), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFF007AFF), + Color(0xFFAF52DE), + Color(0xFF00C7BE), + Color(0xFFFF2D55), + ) diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt new file mode 100644 index 000000000..dc43a1c43 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt @@ -0,0 +1,80 @@ +package org.xmtp.android.example.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = + darkColorScheme( + primary = XMTPBlue, + onPrimary = Color.White, + primaryContainer = XMTPBlueDark, + onPrimaryContainer = Color.White, + secondary = PurpleGrey80, + onSecondary = Color.Black, + secondaryContainer = PurpleGrey40, + onSecondaryContainer = Color.White, + tertiary = Pink80, + onTertiary = Color.Black, + background = Color(0xFF121212), + onBackground = Color.White, + surface = Color(0xFF1E1E1E), + onSurface = Color.White, + surfaceVariant = Color(0xFF2C2C2E), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F), + ) + +private val LightColorScheme = + lightColorScheme( + primary = XMTPBlue, + onPrimary = Color.White, + primaryContainer = XMTPBlueLight, + onPrimaryContainer = Color.White, + secondary = PurpleGrey40, + onSecondary = Color.White, + secondaryContainer = PurpleGrey80, + onSecondaryContainer = Color.Black, + tertiary = Pink40, + onTertiary = Color.White, + background = Color(0xFFF8F8F8), + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color(0xFFE8E8ED), + onSurfaceVariant = Color(0xFF49454F), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), + ) + +@Composable +fun XMTPTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt new file mode 100644 index 000000000..2934d1754 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt @@ -0,0 +1,131 @@ +package org.xmtp.android.example.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = + Typography( + displayLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt index 770b29b2b..104be8b2d 100644 --- a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt +++ b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt @@ -2,20 +2,114 @@ package org.xmtp.android.example.utils import android.accounts.AccountManager import android.content.Context -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties +import android.content.SharedPreferences import android.util.Base64.NO_WRAP import android.util.Base64.decode import android.util.Base64.encodeToString +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import org.xmtp.android.example.R -import java.security.KeyStore -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey +import timber.log.Timber class KeyUtil( - val context: Context, + private val context: Context, ) { - private val PREFS_NAME = "EncryptionPref" + private companion object { + const val ENCRYPTED_PREFS_NAME = "EncryptedKeyPref" + const val LEGACY_PREFS_NAME = "EncryptionPref" // Old name for migration + const val SETTINGS_PREFS = "SettingsPref" + const val KEY_ENVIRONMENT = "xmtp_environment" + const val KEY_HIDE_DELETED_MESSAGES = "hide_deleted_messages" + const val KEY_MIGRATION_COMPLETE = "migration_complete" + + // Key prefixes + const val PREFIX_DB_KEY = "xmtp-dev-" + const val PREFIX_WALLET_KEY = "xmtp-wallet-" + } + + private val masterKey: MasterKey by lazy { + MasterKey + .Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + private val encryptedPrefs: SharedPreferences by lazy { + try { + val prefs = + EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + // Migrate from legacy prefs if needed + migrateLegacyPrefsIfNeeded(prefs) + prefs + } catch (e: Exception) { + Timber.e(e, "Failed to create EncryptedSharedPreferences, falling back to regular prefs") + // Fallback to regular SharedPreferences if encryption fails (e.g., on some devices) + context.getSharedPreferences(ENCRYPTED_PREFS_NAME, Context.MODE_PRIVATE) + } + } + + /** + * Migrate keys from the legacy SharedPreferences name to the new one. + * This prevents data loss for existing users when the prefs filename was changed. + */ + private fun migrateLegacyPrefsIfNeeded(newPrefs: SharedPreferences) { + // Check if migration is already complete + if (newPrefs.getBoolean(KEY_MIGRATION_COMPLETE, false)) { + return + } + + try { + // Try to open the legacy prefs + val legacyPrefs = + EncryptedSharedPreferences.create( + context, + LEGACY_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + // Copy all entries from legacy to new + val allEntries = legacyPrefs.all + if (allEntries.isNotEmpty()) { + Timber.d("Migrating ${allEntries.size} keys from legacy prefs") + val editor = newPrefs.edit() + for ((key, value) in allEntries) { + when (value) { + is String -> editor.putString(key, value) + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is Float -> editor.putFloat(key, value) + is Set<*> -> { + @Suppress("UNCHECKED_CAST") + editor.putStringSet(key, value as Set) + } + } + } + editor.putBoolean(KEY_MIGRATION_COMPLETE, true) + editor.apply() + Timber.d("Migration complete") + } else { + // No legacy data, mark migration as complete + newPrefs.edit().putBoolean(KEY_MIGRATION_COMPLETE, true).apply() + } + } catch (e: Exception) { + // Legacy prefs don't exist or can't be read - that's fine for new users + Timber.d("No legacy prefs to migrate: ${e.message}") + newPrefs.edit().putBoolean(KEY_MIGRATION_COMPLETE, true).apply() + } + } + + private val settingsPrefs: SharedPreferences by lazy { + context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE) + } fun loadKeys(): String? { val accountManager = AccountManager.get(context) @@ -29,23 +123,94 @@ class KeyUtil( address: String, dbEncryptionKey: ByteArray?, ) { - val alias = "xmtp-dev-${address.lowercase()}" - - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val editor = prefs.edit() - editor.putString(alias, encodeToString(dbEncryptionKey, NO_WRAP)) - editor.apply() + val alias = "$PREFIX_DB_KEY${address.lowercase()}" + encryptedPrefs + .edit() + .putString(alias, encodeToString(dbEncryptionKey, NO_WRAP)) + .apply() } fun retrieveKey(address: String): ByteArray? { - val alias = "xmtp-dev-${address.lowercase()}" - - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val keyString = prefs.getString(alias, null) - return if (keyString != null) { - decode(keyString, NO_WRAP) - } else { - null + val alias = "$PREFIX_DB_KEY${address.lowercase()}" + val keyString = encryptedPrefs.getString(alias, null) + return keyString?.let { + try { + decode(it, NO_WRAP) + } catch (e: Exception) { + Timber.e(e, "Failed to decode key") + null + } } } + + /** + * Store the wallet private key for signing. + * Uses EncryptedSharedPreferences backed by Android Keystore. + */ + fun storePrivateKey( + address: String, + privateKeyBytes: ByteArray, + ) { + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + encryptedPrefs + .edit() + .putString(alias, encodeToString(privateKeyBytes, NO_WRAP)) + .apply() + } + + /** + * Retrieve the wallet private key for signing. + * Uses EncryptedSharedPreferences backed by Android Keystore. + */ + fun retrievePrivateKey(address: String): ByteArray? { + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + val keyString = encryptedPrefs.getString(alias, null) + return keyString?.let { + try { + decode(it, NO_WRAP) + } catch (e: Exception) { + Timber.e(e, "Failed to decode private key") + null + } + } + } + + /** + * Clear the wallet private key. + */ + fun clearPrivateKey(address: String) { + val alias = "$PREFIX_WALLET_KEY${address.lowercase()}" + encryptedPrefs.edit().remove(alias).apply() + } + + /** + * Store the selected environment. + */ + fun storeEnvironment(environment: String) { + settingsPrefs.edit().putString(KEY_ENVIRONMENT, environment).apply() + } + + /** + * Retrieve the selected environment. + */ + fun retrieveEnvironment(): String? = settingsPrefs.getString(KEY_ENVIRONMENT, null) + + /** + * Clear the environment setting. + */ + fun clearEnvironment() { + settingsPrefs.edit().remove(KEY_ENVIRONMENT).apply() + } + + /** + * Store hide deleted messages setting. + */ + fun setHideDeletedMessages(hide: Boolean) { + settingsPrefs.edit().putBoolean(KEY_HIDE_DELETED_MESSAGES, hide).apply() + } + + /** + * Retrieve hide deleted messages setting. + */ + fun getHideDeletedMessages(): Boolean = settingsPrefs.getBoolean(KEY_HIDE_DELETED_MESSAGES, false) } diff --git a/example/src/main/res/color/switch_thumb_color.xml b/example/src/main/res/color/switch_thumb_color.xml new file mode 100644 index 000000000..c1db29c52 --- /dev/null +++ b/example/src/main/res/color/switch_thumb_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/example/src/main/res/color/switch_track_color.xml b/example/src/main/res/color/switch_track_color.xml new file mode 100644 index 000000000..9ab59648f --- /dev/null +++ b/example/src/main/res/color/switch_track_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/example/src/main/res/drawable/bottom_sheet_handle.xml b/example/src/main/res/drawable/bottom_sheet_handle.xml new file mode 100644 index 000000000..a6b72dbc2 --- /dev/null +++ b/example/src/main/res/drawable/bottom_sheet_handle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/drawer_avatar_background.xml b/example/src/main/res/drawable/drawer_avatar_background.xml new file mode 100644 index 000000000..bc4f0561d --- /dev/null +++ b/example/src/main/res/drawable/drawer_avatar_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/example/src/main/res/drawable/file_attachment_background.xml b/example/src/main/res/drawable/file_attachment_background.xml new file mode 100644 index 000000000..930de6330 --- /dev/null +++ b/example/src/main/res/drawable/file_attachment_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/file_attachment_background_received.xml b/example/src/main/res/drawable/file_attachment_background_received.xml new file mode 100644 index 000000000..678d124ac --- /dev/null +++ b/example/src/main/res/drawable/file_attachment_background_received.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/ic_account_circle_24.xml b/example/src/main/res/drawable/ic_account_circle_24.xml new file mode 100644 index 000000000..3cbd3c447 --- /dev/null +++ b/example/src/main/res/drawable/ic_account_circle_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_arrow_back_24.xml b/example/src/main/res/drawable/ic_arrow_back_24.xml new file mode 100644 index 000000000..bab545a70 --- /dev/null +++ b/example/src/main/res/drawable/ic_arrow_back_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_attach_file_24.xml b/example/src/main/res/drawable/ic_attach_file_24.xml new file mode 100644 index 000000000..b2943e262 --- /dev/null +++ b/example/src/main/res/drawable/ic_attach_file_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_audio_24.xml b/example/src/main/res/drawable/ic_audio_24.xml new file mode 100644 index 000000000..f92cc4cdd --- /dev/null +++ b/example/src/main/res/drawable/ic_audio_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_bug_report_24.xml b/example/src/main/res/drawable/ic_bug_report_24.xml new file mode 100644 index 000000000..5e360b2e3 --- /dev/null +++ b/example/src/main/res/drawable/ic_bug_report_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_camera_24.xml b/example/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 000000000..0b57fb0aa --- /dev/null +++ b/example/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/example/src/main/res/drawable/ic_check_24.xml b/example/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 000000000..0432fa69b --- /dev/null +++ b/example/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_check_double.xml b/example/src/main/res/drawable/ic_check_double.xml new file mode 100644 index 000000000..442a05083 --- /dev/null +++ b/example/src/main/res/drawable/ic_check_double.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/example/src/main/res/drawable/ic_close_24.xml b/example/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 000000000..d53ab5e47 --- /dev/null +++ b/example/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_content_copy_24.xml b/example/src/main/res/drawable/ic_content_copy_24.xml new file mode 100644 index 000000000..8bf74a77d --- /dev/null +++ b/example/src/main/res/drawable/ic_content_copy_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_copy_24.xml b/example/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 000000000..e03f61805 --- /dev/null +++ b/example/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_delete_24.xml b/example/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 000000000..3ce7304db --- /dev/null +++ b/example/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_edit_24.xml b/example/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 000000000..2844bafeb --- /dev/null +++ b/example/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_emoji_24.xml b/example/src/main/res/drawable/ic_emoji_24.xml new file mode 100644 index 000000000..c636d5bcd --- /dev/null +++ b/example/src/main/res/drawable/ic_emoji_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_gif_24.xml b/example/src/main/res/drawable/ic_gif_24.xml new file mode 100644 index 000000000..9882f69ae --- /dev/null +++ b/example/src/main/res/drawable/ic_gif_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/example/src/main/res/drawable/ic_group_24.xml b/example/src/main/res/drawable/ic_group_24.xml new file mode 100644 index 000000000..3436691a2 --- /dev/null +++ b/example/src/main/res/drawable/ic_group_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/example/src/main/res/drawable/ic_image_24.xml b/example/src/main/res/drawable/ic_image_24.xml new file mode 100644 index 000000000..8f7314354 --- /dev/null +++ b/example/src/main/res/drawable/ic_image_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/example/src/main/res/drawable/ic_info_24.xml b/example/src/main/res/drawable/ic_info_24.xml new file mode 100644 index 000000000..2096c119b --- /dev/null +++ b/example/src/main/res/drawable/ic_info_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/example/src/main/res/drawable/ic_keyboard_24.xml b/example/src/main/res/drawable/ic_keyboard_24.xml new file mode 100644 index 000000000..45da6b6b1 --- /dev/null +++ b/example/src/main/res/drawable/ic_keyboard_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_logout_24.xml b/example/src/main/res/drawable/ic_logout_24.xml new file mode 100644 index 000000000..86971585a --- /dev/null +++ b/example/src/main/res/drawable/ic_logout_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_menu_24.xml b/example/src/main/res/drawable/ic_menu_24.xml new file mode 100644 index 000000000..9dc1fca8a --- /dev/null +++ b/example/src/main/res/drawable/ic_menu_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_mic_24.xml b/example/src/main/res/drawable/ic_mic_24.xml new file mode 100644 index 000000000..953071839 --- /dev/null +++ b/example/src/main/res/drawable/ic_mic_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_more_vert_24.xml b/example/src/main/res/drawable/ic_more_vert_24.xml new file mode 100644 index 000000000..3b2167ece --- /dev/null +++ b/example/src/main/res/drawable/ic_more_vert_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_pdf_24.xml b/example/src/main/res/drawable/ic_pdf_24.xml new file mode 100644 index 000000000..e0940f5f7 --- /dev/null +++ b/example/src/main/res/drawable/ic_pdf_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_reply_24.xml b/example/src/main/res/drawable/ic_reply_24.xml new file mode 100644 index 000000000..5514cbf4e --- /dev/null +++ b/example/src/main/res/drawable/ic_reply_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_search_24.xml b/example/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 000000000..d9a217a17 --- /dev/null +++ b/example/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_video_24.xml b/example/src/main/res/drawable/ic_video_24.xml new file mode 100644 index 000000000..57d3e17a6 --- /dev/null +++ b/example/src/main/res/drawable/ic_video_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/ic_visibility_off_24.xml b/example/src/main/res/drawable/ic_visibility_off_24.xml new file mode 100644 index 000000000..a91f4ec69 --- /dev/null +++ b/example/src/main/res/drawable/ic_visibility_off_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/example/src/main/res/drawable/message_bubble_received.xml b/example/src/main/res/drawable/message_bubble_received.xml new file mode 100644 index 000000000..08d766949 --- /dev/null +++ b/example/src/main/res/drawable/message_bubble_received.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/message_bubble_sent.xml b/example/src/main/res/drawable/message_bubble_sent.xml new file mode 100644 index 000000000..7b127a3f7 --- /dev/null +++ b/example/src/main/res/drawable/message_bubble_sent.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/reaction_button_background.xml b/example/src/main/res/drawable/reaction_button_background.xml new file mode 100644 index 000000000..bd49c6748 --- /dev/null +++ b/example/src/main/res/drawable/reaction_button_background.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/example/src/main/res/drawable/reaction_button_selected_background.xml b/example/src/main/res/drawable/reaction_button_selected_background.xml new file mode 100644 index 000000000..9be313eb5 --- /dev/null +++ b/example/src/main/res/drawable/reaction_button_selected_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/example/src/main/res/drawable/reply_background.xml b/example/src/main/res/drawable/reply_background.xml new file mode 100644 index 000000000..ccba3eced --- /dev/null +++ b/example/src/main/res/drawable/reply_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/reply_background_received.xml b/example/src/main/res/drawable/reply_background_received.xml new file mode 100644 index 000000000..53bef7468 --- /dev/null +++ b/example/src/main/res/drawable/reply_background_received.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/reply_background_sent.xml b/example/src/main/res/drawable/reply_background_sent.xml new file mode 100644 index 000000000..04bf82d2a --- /dev/null +++ b/example/src/main/res/drawable/reply_background_sent.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/drawable/system_message_background.xml b/example/src/main/res/drawable/system_message_background.xml new file mode 100644 index 000000000..effd73d9e --- /dev/null +++ b/example/src/main/res/drawable/system_message_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/example/src/main/res/drawable/thumbnail_border.xml b/example/src/main/res/drawable/thumbnail_border.xml new file mode 100644 index 000000000..2e66ebaa2 --- /dev/null +++ b/example/src/main/res/drawable/thumbnail_border.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/example/src/main/res/layout/activity_connect_wallet.xml b/example/src/main/res/layout/activity_connect_wallet.xml deleted file mode 100644 index 8b4cdd196..000000000 --- a/example/src/main/res/layout/activity_connect_wallet.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - diff --git a/example/src/main/res/layout/activity_conversation_detail.xml b/example/src/main/res/layout/activity_conversation_detail.xml deleted file mode 100644 index 2c2eb9fff..000000000 --- a/example/src/main/res/layout/activity_conversation_detail.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml deleted file mode 100644 index da5be9a7e..000000000 --- a/example/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/example/src/main/res/layout/bottom_sheet_log_viewer.xml b/example/src/main/res/layout/bottom_sheet_log_viewer.xml deleted file mode 100644 index 0d79edeb1..000000000 --- a/example/src/main/res/layout/bottom_sheet_log_viewer.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/example/src/main/res/layout/bottom_sheet_new_conversation.xml b/example/src/main/res/layout/bottom_sheet_new_conversation.xml deleted file mode 100644 index 9c1b0b504..000000000 --- a/example/src/main/res/layout/bottom_sheet_new_conversation.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - diff --git a/example/src/main/res/layout/bottom_sheet_new_group.xml b/example/src/main/res/layout/bottom_sheet_new_group.xml deleted file mode 100644 index 3e9cfed93..000000000 --- a/example/src/main/res/layout/bottom_sheet_new_group.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/example/src/main/res/layout/fragment_connect_wallet.xml b/example/src/main/res/layout/fragment_connect_wallet.xml deleted file mode 100644 index 8d93ca824..000000000 --- a/example/src/main/res/layout/fragment_connect_wallet.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - diff --git a/example/src/main/res/layout/list_item_conversation.xml b/example/src/main/res/layout/list_item_conversation.xml deleted file mode 100644 index f11dcffa9..000000000 --- a/example/src/main/res/layout/list_item_conversation.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/example/src/main/res/layout/list_item_conversation_footer.xml b/example/src/main/res/layout/list_item_conversation_footer.xml deleted file mode 100644 index 90a625375..000000000 --- a/example/src/main/res/layout/list_item_conversation_footer.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/example/src/main/res/layout/list_item_message.xml b/example/src/main/res/layout/list_item_message.xml deleted file mode 100644 index 690a1a126..000000000 --- a/example/src/main/res/layout/list_item_message.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/example/src/main/res/menu/menu_conversation_detail.xml b/example/src/main/res/menu/menu_conversation_detail.xml new file mode 100644 index 000000000..f55261859 --- /dev/null +++ b/example/src/main/res/menu/menu_conversation_detail.xml @@ -0,0 +1,9 @@ + + + + diff --git a/example/src/main/res/menu/menu_drawer.xml b/example/src/main/res/menu/menu_drawer.xml new file mode 100644 index 000000000..f68990e7e --- /dev/null +++ b/example/src/main/res/menu/menu_drawer.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/menu/menu_member.xml b/example/src/main/res/menu/menu_member.xml new file mode 100644 index 000000000..82b8c272a --- /dev/null +++ b/example/src/main/res/menu/menu_member.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/example/src/main/res/values/colors.xml b/example/src/main/res/values/colors.xml index f8c6127d3..4c7738c0a 100644 --- a/example/src/main/res/values/colors.xml +++ b/example/src/main/res/values/colors.xml @@ -1,5 +1,49 @@ + + #FC4F37 + #E0422D + #FF7A68 + + + #FFFFFF + #F5F5F5 + #FAFAFA + + + #1A1A1A + #666666 + #999999 + #FFFFFF + + + #34C759 + #FF9500 + #FF3B30 + #5856D6 + + + #E0E0E0 + + + #EFFDDE + #1A1A1A + #6FB87C + #FFFFFF + #1A1A1A + #8E8E93 + #E5DDD5 + + + #4CAF50 + #4A635B + #FC4F37 + #666666 + + + #FC4F37 + + #FFBB86FC #FF6200EE #FF3700B3 @@ -7,4 +51,4 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + diff --git a/example/src/main/res/values/dimens.xml b/example/src/main/res/values/dimens.xml index f5260bbc1..ede167765 100644 --- a/example/src/main/res/values/dimens.xml +++ b/example/src/main/res/values/dimens.xml @@ -2,4 +2,5 @@ 16dp 48dp + 60dp diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index dbb35948f..d9d075c8c 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -7,20 +7,136 @@ Generate wallet Connect wallet No wallet apps installed + Select Environment + Production + Dev + + Dev + Production + Local + + Log Level + + Off + Error + Warn + Info + Debug + Trace + Disconnect wallet Copy address + + Wallet Information + Wallet Address + Inbox ID + Installation ID + Environment + LibXMTP Version + Copy Inbox ID + Copy Installation ID + Copy Environment + Copy LibXMTP Version + %1$s copied + Signed in as %1$s on %2$s - New message + New Message + New Message + Start a new conversation + Start a conversation with someone Enter Ethereum address + 0x… + Enter a valid Ethereum address (0x followed by 40 characters) + Start Conversation Create conversation No messages yet You: %1$s XMTP Direct Message + Direct Message + Group Chat + Info + Loading… + + + New Group + Add members to create a group chat + Add member + Add + %1$d member(s) added + Create Group + Add another member + Add at least 1 member to create a group + Invalid Ethereum address + Group name (optional) + Recent Contacts + Clear + Last messaged %1$s + + + Group Info + Group Details + Created + Members + %1$d members + Your Role + Members + Leave Group + Are you sure you want to leave this group? + Leave + Super Admin + Admin + Member + Inbox: %1$s + Promote to Admin + Demote from Admin + Remove Member + Promote + Demote + Remove + Promote %1$s to admin? + Demote %1$s from admin? + Remove %1$s from the group? + + + Profile + User Details + Linked Identities + Send Message + Unknown - Type a message… + Message + Reply + Edit + Delete + Edit Message + Editing message + Replying to %1$s + Emoji + Attach file + Voice message + Search + Search messages + No results found + Cancel + + + Send Attachment + Camera + Gallery + File + GIF + Sending attachment… + File is too large (max 10MB) + Failed to send attachment + Loading… + Download + Preview + Add a caption… + Send + %1$d of %2$d View Logs @@ -28,5 +144,20 @@ No log files found Activate Persistent Logs Deactivate Persistent Logs - + + + Settings + Persistent Logs + Enable debug logging to file + + + New Conversation + New Group + Settings + Logs + Account + Open navigation drawer + Close navigation drawer + Hide Deleted Messages + diff --git a/example/src/main/res/values/themes.xml b/example/src/main/res/values/themes.xml index 88ae2efc5..cfb70da3b 100644 --- a/example/src/main/res/values/themes.xml +++ b/example/src/main/res/values/themes.xml @@ -1,5 +1,75 @@ - + + + + + + + + + + + + + + + + + + + diff --git a/example/src/main/res/xml/file_paths.xml b/example/src/main/res/xml/file_paths.xml index a0f87037d..b8353e1a2 100644 --- a/example/src/main/res/xml/file_paths.xml +++ b/example/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt index a85f2a7db..06f71c95a 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt @@ -251,6 +251,7 @@ class DeleteMessageTest : BaseInstrumentedTest() { assertNotNull(deletedContent) assertTrue(deletedContent?.deletedBy is DeletedBy.Sender) + // Verify the content type is now deletedMessage assertEquals("xmtp.org", boEnrichedAfterDeletion?.contentTypeId?.authorityId) assertEquals("deletedMessage", boEnrichedAfterDeletion?.contentTypeId?.typeId) } diff --git a/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt new file mode 100644 index 000000000..6837f5ae8 --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt @@ -0,0 +1,269 @@ +package org.xmtp.android.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.xmtp.android.library.codecs.EditMessage +import org.xmtp.android.library.codecs.TextCodec + +@RunWith(AndroidJUnit4::class) +@Ignore("enable when edit message is released") +class EditMessageTest : BaseInstrumentedTest() { + private lateinit var fixtures: TestFixtures + private lateinit var alixClient: Client + private lateinit var boClient: Client + private lateinit var caroClient: Client + + @Before + override fun setUp() { + super.setUp() + fixtures = runBlocking { createFixtures() } + alixClient = fixtures.alixClient + boClient = fixtures.boClient + caroClient = fixtures.caroClient + } + + @Test + fun testSenderCanEditOwnMessage() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + val originalText = "Hello, this message will be edited" + val messageId = + runBlocking { + alixGroup.send(originalText) + } + + runBlocking { alixGroup.sync() } + var messages = runBlocking { alixGroup.messages() } + assertTrue(messages.any { it.id == messageId }) + + val editedText = "Hello, this message has been edited" + val editedContent = TextCodec().encode(editedText) + val editMessageId = + runBlocking { + alixGroup.editMessage(messageId, editedContent.toByteArray()) + } + assertNotNull(editMessageId) + + runBlocking { alixGroup.sync() } + messages = runBlocking { alixGroup.messages() } + assertTrue(messages.any { it.id == editMessageId }) + } + + @Test + fun testRegularUserCannotEditOthersMessage() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + runBlocking { boClient.conversations.sync() } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + val messageId = + runBlocking { + alixGroup.send("Hello from Alix") + } + + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + val editedContent = TextCodec().encode("Edited by Bo") + + assertThrows(XMTPException::class.java) { + runBlocking { + boGroup.editMessage(messageId, editedContent.toByteArray()) + } + } + } + + @Test + fun testEditMessageInDm() { + val alixDm = + runBlocking { + alixClient.conversations.findOrCreateDm(boClient.inboxId) + } + + val originalText = "Hello in DM" + val messageId = + runBlocking { + alixDm.send(originalText) + } + + runBlocking { alixDm.sync() } + var messages = runBlocking { alixDm.messages() } + assertTrue(messages.any { it.id == messageId }) + + val editedText = "Edited hello in DM" + val editedContent = TextCodec().encode(editedText) + val editMessageId = + runBlocking { + alixDm.editMessage(messageId, editedContent.toByteArray()) + } + assertNotNull(editMessageId) + + runBlocking { alixDm.sync() } + messages = runBlocking { alixDm.messages() } + assertTrue(messages.any { it.id == editMessageId }) + } + + @Test + fun testEditMessageViaConversation() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + val conversation: Conversation = Conversation.Group(alixGroup) + + val messageId = + runBlocking { + conversation.send("Hello via conversation") + } + + val editedContent = TextCodec().encode("Edited via conversation") + val editMessageId = + runBlocking { + conversation.editMessage(messageId, editedContent.toByteArray()) + } + assertNotNull(editMessageId) + } + + @Test + fun testEditMessageWithInvalidId() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + val editedContent = TextCodec().encode("Edited content") + + assertThrows(XMTPException::class.java) { + runBlocking { + alixGroup.editMessage( + "0000000000000000000000000000000000000000000000000000000000000000", + editedContent.toByteArray(), + ) + } + } + } + + @Test + fun testReceiverSeesEditedMessageContent() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + runBlocking { boClient.conversations.sync() } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + val originalText = "Original message content" + val messageId = + runBlocking { + alixGroup.send(originalText) + } + + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + var boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val boOriginalEnriched = boEnrichedMessages.find { it.id == messageId } + assertNotNull(boOriginalEnriched) + assertEquals(originalText, boOriginalEnriched?.content()) + + val editedText = "Edited message content" + val editedContent = TextCodec().encode(editedText) + runBlocking { + alixGroup.editMessage(messageId, editedContent.toByteArray()) + alixGroup.sync() + } + + runBlocking { boGroup.sync() } + + boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val boEnrichedAfterEdit = boEnrichedMessages.find { it.id == messageId } + + assertNotNull(boEnrichedAfterEdit) + + // The enriched message should now show the edited content + assertEquals(editedText, boEnrichedAfterEdit?.content()) + + // Verify the content type is still text (the original type is preserved) + assertEquals("xmtp.org", boEnrichedAfterEdit?.contentTypeId?.authorityId) + assertEquals("text", boEnrichedAfterEdit?.contentTypeId?.typeId) + } + + @Test + fun testMultipleEditsShowLatest() { + val alixGroup = + runBlocking { + alixClient.conversations.newGroup(listOf(boClient.inboxId)) + } + + runBlocking { boClient.conversations.sync() } + val boGroup = + runBlocking { + boClient.conversations.listGroups().first { it.id == alixGroup.id } + } + + val originalText = "Version 1" + val messageId = + runBlocking { + alixGroup.send(originalText) + } + + runBlocking { + alixGroup.sync() + boGroup.sync() + } + + // First edit + val edit1Text = "Version 2" + val edit1Content = TextCodec().encode(edit1Text) + runBlocking { + alixGroup.editMessage(messageId, edit1Content.toByteArray()) + alixGroup.sync() + } + + // Second edit + val edit2Text = "Version 3" + val edit2Content = TextCodec().encode(edit2Text) + runBlocking { + alixGroup.editMessage(messageId, edit2Content.toByteArray()) + alixGroup.sync() + } + + runBlocking { boGroup.sync() } + + val boEnrichedMessages = runBlocking { boGroup.enrichedMessages() } + val boEnrichedMessage = boEnrichedMessages.find { it.id == messageId } + + assertNotNull(boEnrichedMessage) + // Should show the latest edit + assertEquals(edit2Text, boEnrichedMessage?.content()) + } + + private fun assertTrue(condition: Boolean) { + org.junit.Assert.assertTrue(condition) + } +}