From a2a4433a9db17033084058f7380b65004510c8fe Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 27 May 2026 06:22:14 +0000 Subject: [PATCH] feat: add profile screen (#5) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/5 --- .../composeResources/drawable/ic_badge.xml | 9 + .../composeResources/drawable/ic_bitcoin.xml | 9 + .../composeResources/drawable/ic_chat.xml | 9 + .../composeResources/drawable/ic_globe.xml | 9 + .../composeResources/drawable/ic_share.xml | 9 + .../androidMain/kotlin/su/reya/coop/App.kt | 8 + .../kotlin/su/reya/coop/Navigation.kt | 3 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 12 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 18 +- .../su/reya/coop/screens/ProfileScreen.kt | 243 ++++++++++++++++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 14 + 11 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_badge.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_bitcoin.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_chat.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_globe.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_share.xml create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_badge.xml b/composeApp/src/androidMain/composeResources/drawable/ic_badge.xml new file mode 100644 index 0000000..f2d834b --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_badge.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_bitcoin.xml b/composeApp/src/androidMain/composeResources/drawable/ic_bitcoin.xml new file mode 100644 index 0000000..b190e5e --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_bitcoin.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_chat.xml b/composeApp/src/androidMain/composeResources/drawable/ic_chat.xml new file mode 100644 index 0000000..df11a25 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_globe.xml b/composeApp/src/androidMain/composeResources/drawable/ic_globe.xml new file mode 100644 index 0000000..57104ef --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_globe.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_share.xml b/composeApp/src/androidMain/composeResources/drawable/ic_share.xml new file mode 100644 index 0000000..d4e9c12 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 4cba833..e8628c0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -54,6 +54,7 @@ import su.reya.coop.screens.MyQrScreen import su.reya.coop.screens.NewChatScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.ScanScreen @@ -232,6 +233,13 @@ fun App() { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + val profile: Screen.Profile = backStackEntry.toRoute() + ProfileScreen( + pubkey = profile.pubkey, + onBack = { navController.popBackStack() }, + ) + } composable { backStackEntry -> NewChatScreen( onBack = { navController.popBackStack() }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 1b4ddb4..dbf5bc3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -9,6 +9,9 @@ sealed interface Screen { @Serializable data class Chat(val id: Long) : Screen + @Serializable + data class Profile(val pubkey: String) : Screen + @Serializable data object NewChat : Screen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index f315106..c7f8f87 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -51,8 +51,10 @@ import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.flow.first import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.LocalNavController import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen import su.reya.coop.formatAsGroupHeader import su.reya.coop.roomId import su.reya.coop.shared.Avatar @@ -66,6 +68,7 @@ fun ChatScreen( onBack: () -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current + val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current val listState = rememberLazyListState() @@ -143,7 +146,14 @@ fun ChatScreen( topBar = { TopAppBar( title = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + room.members.firstOrNull()?.let { pubkey -> + navController.navigate(Screen.Profile(pubkey.toBech32())) + } + } + ) { if (loading) { LoadingIndicator( modifier = Modifier.size(32.dp), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index d1c0d03..a5db59f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,5 +1,6 @@ package su.reya.coop.screens +import android.content.ClipData import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -58,6 +59,8 @@ 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.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res @@ -86,6 +89,7 @@ fun HomeScreen( ) { val navController = LocalNavController.current val snackbarHostState = LocalSnackbarHostState.current + val clipboardManager = LocalClipboard.current val viewModel = LocalNostrViewModel.current val currentUser = viewModel.currentUser() ?: return @@ -322,18 +326,20 @@ fun HomeScreen( ) { OutlinedButton( onClick = { - dismissAndRun { navController.navigate(Screen.MyQr) } + scope.launch { + pubkey?.let { + val bech32 = it.toBech32() + val data = ClipData.newPlainText(bech32, bech32) + clipboardManager.setClipEntry(ClipEntry(data)) + } + } }, ) { Text(text = shortPubkey) } FilledIconButton( onClick = { - scope.launch { - sheetState.hide() - showBottomSheet = false - navController.navigate(Screen.MyQr) - } + dismissAndRun { navController.navigate(Screen.MyQr) } }, shape = MaterialShapes.Square.toShape() ) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt new file mode 100644 index 0000000..5c0f924 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -0,0 +1,243 @@ +package su.reya.coop.screens + +import android.content.Intent +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.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_chat +import coop.composeapp.generated.resources.ic_share +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalNavController +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.shared.Avatar +import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.short + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ProfileScreen( + onBack: () -> Unit, + pubkey: String +) { + val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + val navController = LocalNavController.current + val viewModel = LocalNostrViewModel.current + + val scope = rememberCoroutineScope() + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsState(initial = null) + + val profile = metadata?.asRecord() + val displayName = profile?.displayName ?: profile?.name ?: "No name" + val nip05 = profile?.nip05 ?: pubkey.short() + val picture = profile?.picture + val details = remember(profile) { + listOf( + "Username:" to (profile?.name ?: "None"), + "Website:" to (profile?.website ?: "None"), + "Lightning Address:" to (profile?.lud16 ?: "None"), + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(120.dp) + .clip(MaterialShapes.Cookie9Sided.toShape()), + contentAlignment = Alignment.Center + ) { + Avatar( + picture = picture, + description = "Profile picture", + modifier = Modifier.fillMaxSize(), + shape = MaterialShapes.Cookie9Sided.toShape(), + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = displayName, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontFamily = getExpressiveFontFamily() + ), + ) + Text( + text = nip05, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + } + Spacer(modifier = Modifier.size(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + FilledTonalIconButton( + onClick = { + scope.launch { + try { + val roomId = viewModel.createChatRoom(listOf(pubkey)) + navController.navigate(Screen.Chat(roomId)) + } catch (e: Exception) { + e.message?.let { snackbarHostState.showSnackbar(it) } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.MediumContainerHeight), + ) { + Icon( + painter = painterResource(Res.drawable.ic_chat), + contentDescription = "New Chat" + ) + } + Text( + text = "Message", + style = MaterialTheme.typography.labelSmall + ) + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + FilledTonalIconButton( + onClick = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, pubkey.toBech32()) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.MediumContainerHeight), + ) { + Icon( + painter = painterResource(Res.drawable.ic_share), + contentDescription = "Share" + ) + } + Text( + text = "Share", + style = MaterialTheme.typography.labelMedium + ) + } + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1.5f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + details.forEachIndexed { index, (label, value) -> + SegmentedListItem( + onClick = { }, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = details.size + ), + content = { Text(label) }, + supportingContent = { Text(value) }, + ) + } + } + } + } + } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 4f2d0a8..241090d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -417,8 +417,19 @@ class NostrViewModel( .tags(to.map { Tag.publicKey(it) }) .build(nostr.signer.currentUser!!) + // Check if the room already exists + val id = rumor.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == id } + + // If the room already exists, return its ID + if (existingRoom != null) { + return existingRoom.id + } + // Create a room from the rumor event val room = Room.new(rumor, nostr.signer.currentUser!!) + + // Update the chat rooms state _chatRooms.update { currentRooms -> currentRooms + room } @@ -477,6 +488,9 @@ class NostrViewModel( } fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + if (message.isEmpty()) { + showError("Message cannot be empty") + } viewModelScope.launch { try { val room = getChatRoom(roomId)