add profile screen

This commit is contained in:
2026-05-27 09:51:41 +07:00
parent 2080d9d67b
commit e1bb1d5a6b
10 changed files with 313 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,360Q80,327 103.5,303.5Q127,280 160,280L360,280L360,160Q360,127 383.5,103.5Q407,80 440,80L520,80Q553,80 576.5,103.5Q600,127 600,160L600,280L800,280Q833,280 856.5,303.5Q880,327 880,360L880,800Q880,833 856.5,856.5Q833,880 800,880L160,880ZM160,800L800,800Q800,800 800,800Q800,800 800,800L800,360Q800,360 800,360Q800,360 800,360L600,360L600,360Q600,393 576.5,416.5Q553,440 520,440L440,440Q407,440 383.5,416.5Q360,393 360,360L360,360L160,360Q160,360 160,360Q160,360 160,360L160,800Q160,800 160,800Q160,800 160,800ZM240,720L480,720L480,702Q480,685 470.5,670.5Q461,656 444,648Q424,639 403.5,634.5Q383,630 360,630Q337,630 316.5,634.5Q296,639 276,648Q259,656 249.5,670.5Q240,685 240,702L240,720ZM560,660L720,660L720,600L560,600L560,660ZM402.5,582.5Q420,565 420,540Q420,515 402.5,497.5Q385,480 360,480Q335,480 317.5,497.5Q300,515 300,540Q300,565 317.5,582.5Q335,600 360,600Q385,600 402.5,582.5ZM560,540L720,540L720,480L560,480L560,540ZM440,360L520,360L520,160L440,160L440,360ZM480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M360,840L360,760L240,760L240,680L320,680L320,280L240,280L240,200L360,200L360,120L440,120L440,200L520,200L520,120L600,120L600,205L600,205Q652,219 686,261.5Q720,304 720,360Q720,389 710,415.5Q700,442 682,463Q717,484 738.5,520Q760,556 760,600Q760,666 713,713Q666,760 600,760L600,760L600,840L520,840L520,760L440,760L440,840L360,840ZM400,440L560,440Q593,440 616.5,416.5Q640,393 640,360Q640,327 616.5,303.5Q593,280 560,280L400,280L400,440ZM400,680L600,680Q633,680 656.5,656.5Q680,633 680,600Q680,567 656.5,543.5Q633,520 600,520L400,520L400,680Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,473 799.5,465.5Q799,458 799,453Q794,482 772,501Q750,520 720,520L640,520Q607,520 583.5,496.5Q560,473 560,440L560,400L400,400L400,320Q400,287 423.5,263.5Q447,240 480,240L520,240L520,240Q520,217 532.5,199.5Q545,182 563,171Q543,166 522.5,163Q502,160 480,160Q346,160 253,253Q160,346 160,480Q160,480 160,480Q160,480 160,480L360,480Q426,480 473,527Q520,574 520,640L520,680L400,680L400,790Q420,795 439.5,797.5Q459,800 480,800Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z" />
</vector>

View File

@@ -54,6 +54,7 @@ import su.reya.coop.screens.MyQrScreen
import su.reya.coop.screens.NewChatScreen import su.reya.coop.screens.NewChatScreen
import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.NewIdentityScreen
import su.reya.coop.screens.OnboardingScreen import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
@@ -232,6 +233,13 @@ fun App() {
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
pubkey = profile.pubkey,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry -> composable<Screen.NewChat> { backStackEntry ->
NewChatScreen( NewChatScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },

View File

@@ -9,6 +9,9 @@ sealed interface Screen {
@Serializable @Serializable
data class Chat(val id: Long) : Screen data class Chat(val id: Long) : Screen
@Serializable
data class Profile(val pubkey: String) : Screen
@Serializable @Serializable
data object NewChat : Screen data object NewChat : Screen

View File

@@ -51,8 +51,10 @@ import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -66,6 +68,7 @@ fun ChatScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -143,7 +146,14 @@ fun ChatScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { 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) { if (loading) {
LoadingIndicator( LoadingIndicator(
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),

View File

@@ -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) },
)
}
}
}
}
}
)
}

View File

@@ -477,6 +477,9 @@ class NostrViewModel(
} }
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) { fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
}
viewModelScope.launch { viewModelScope.launch {
try { try {
val room = getChatRoom(roomId) val room = getChatRoom(roomId)