Add active profile screens

This commit is contained in:
Kgothatso Ngako
2026-07-01 15:11:31 +02:00
parent 0d54d1de03
commit 124856ce2d
9 changed files with 588 additions and 4 deletions

View File

@@ -94,6 +94,7 @@ kotlin {
implementation(libs.okio)
implementation(libs.qrose)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)

View File

@@ -0,0 +1,367 @@
package at.torch.compose.ui.composable
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.SwitchVideo
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.lifecycle.viewmodel.compose.viewModel
import at.torch.compose.database.model.NostrEvent
import at.torch.compose.database.model.intermdiate.LocalNostrEvent
import at.torch.compose.extensions.hexToNpubHrp
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar
import at.torch.compose.ui.view.model.ActiveProfileViewModel
import at.torch.compose.ui.view.state.ActiveProfileUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ActiveProfileScreen(
initialActiveProfileUIState: ActiveProfileUIState = ActiveProfileUIState.Loading,
activeUserPublicKey: HexKey,
nostrEventId: HexKey,
onNavigateBack: () -> Unit,
onNavigateToRoute: (Route) -> Unit,
nostrRepository: NostrRepository,
) {
val activeProfileViewModel: ActiveProfileViewModel = viewModel(
factory = ActiveProfileViewModel.factory(
nostrEventId = nostrEventId,
initialActiveProfileUIState = initialActiveProfileUIState,
nostrRepository = nostrRepository,
activeUserPublicKey = activeUserPublicKey
),
)
when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) {
ActiveProfileUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Something went wrong")
}
}
is ActiveProfileUIState.Loaded -> {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Profile"
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
ProfileAvatar(
profile = activeProfileUIState.localNostrEvent.profile,
publicKey = activeUserPublicKey
)
Column {
activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey ->
Text(
text = humanReadableNameOrPubkey,
style = MaterialTheme.typography.titleLarge
)
}
activeProfileUIState.localNostrEvent.profile?.about?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(
modifier = Modifier.height(10.dp)
)
Text(
text = activeUserPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Edit Profile")
)
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Edit Profile"
)
}
Spacer(
modifier = Modifier.weight(1f)
)
Button(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Share and Connect")
)
},
) {
Icon(
Icons.Default.QrCode,
contentDescription = "QR code"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Share Profile"
)
}
Spacer(
modifier = Modifier.weight(1f)
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Change Account")
)
}
) {
Icon(
Icons.Default.ImportExport,
contentDescription = "Change Profile"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Change Account"
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Profile Keys")
)
}
) {
Icon(
Icons.Default.Key,
contentDescription = "Profile Keys"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Profile Keys")
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Network Relays")
)
}
) {
Icon(
Icons.Default.Hub,
contentDescription = "Network Relays"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Network Relays"
)
}
}
item {
Button(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign Out")
)
}
) {
Icon(
Icons.Default.Logout,
contentDescription = "Logout"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text(
"Sign out"
)
}
}
}
}
}
}
ActiveProfileUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
activeProfileViewModel.loadNostrFeed()
}
}
ActiveProfileUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("We couldn't find the local profile. Please try again later.")
}
}
}
}
@Preview
@Composable
private fun UnannouncedProfileScreenPreview() {
_root_ide_package_.at.torch.compose.ui.theme.TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
ActiveProfileScreen(
initialActiveProfileUIState = ActiveProfileUIState.Loaded(
localNostrEvent = LocalNostrEvent(
nostrEvent = NostrEvent(
id = "eventId",
pubKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
content = """Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.""".trimIndent(),
tags = emptyArray(),
sig = "",
kind = MetadataEvent.KIND
),
profile = _root_ide_package_.at.torch.compose.database.model.Profile(
publicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
displayName = "John Doe",
nostrEventId = "nostrEventId",
about = "I been here, there and everywhere."
)
)
),
activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
nostrEventId = "",
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)
}
}
}

View File

@@ -32,6 +32,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute
import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import at.torch.compose.ui.view.model.HomeScreenType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -100,7 +102,7 @@ fun HomeScreen(
IconButton(
onClick = {
onNavigateToEvent.invoke(
_root_ide_package_.at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute(
ActiveProfileRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = homeScreenUIState.profileWithFollowing.nostrEvent.id
)

View File

@@ -13,12 +13,16 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import at.torch.compose.database.repository.DatabaseMarmotRepository
import at.torch.compose.ui.composable.ActiveProfileScreen
import at.torch.compose.ui.composable.ChatRoomCreationScreen
import at.torch.compose.ui.composable.ChatRoomDetailScreen
import at.torch.compose.ui.composable.HomeScreen
import at.torch.compose.ui.composable.NostrEventDetailScreen
import at.torch.compose.ui.composable.UnsyncedProfileScreen
import at.torch.compose.ui.composable.navigation.routes.ActiveProfileRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute
import at.torch.compose.ui.composable.navigation.routes.LoadingRoute
import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import at.torch.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute
import at.torch.compose.ui.composable.navigation.routes.WriteNewNoteRoute
import at.torch.compose.ui.view.state.NavigationUIState
@@ -443,6 +447,23 @@ fun TorchNavHost(
}
}
composable<ActiveProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<NostrEventDetailRoute>()
ActiveProfileScreen(
activeUserPublicKey = route.activeUserPublicKey,
nostrEventId = route.nostrEventId,
nostrRepository = databaseNostrRepository,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRoute = { route ->
navController.navigate(
route = route
)
}
)
}
composable<at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute>()
@@ -492,10 +513,10 @@ fun TorchNavHost(
}
)
}
composable<at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute>()
composable<NostrEventDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<NostrEventDetailRoute>()
at.torch.compose.ui.composable.NostrEventDetailScreen(
NostrEventDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
initialNostrEventDetailUIState = at.torch.compose.ui.view.state.NostrEventDetailUIState.Loading,
nostrEventId = route.nostrEventId,

View File

@@ -0,0 +1,9 @@
package at.torch.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class ActiveProfileRoute(
val activeUserPublicKey: String,
val nostrEventId: String,
): Route()

View File

@@ -0,0 +1,101 @@
package at.torch.compose.ui.composable.widgets
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import at.torch.compose.ui.theme.BluePill
import io.github.alexzhirkevich.qrose.options.QrBrush
import io.github.alexzhirkevich.qrose.options.QrLogoPadding
import io.github.alexzhirkevich.qrose.options.QrLogoShape
import io.github.alexzhirkevich.qrose.options.solid
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource
import torch.composeapp.generated.resources.Res
import torch.composeapp.generated.resources.compose_multiplatform
class QRCodeBackgroundPainter(
private val painter: Painter,
private val backgroundColor: Color = BluePill,
private val padding: Dp = 0.dp,
): Painter() {
override val intrinsicSize: Size = painter.intrinsicSize
override fun DrawScope.onDraw() {
val paddingPx = padding.toPx() // Convert Dp to Px
// Calculate the size excluding the padding
val paddedSize = Size(
width = size.width - paddingPx * 2,
height = size.height - paddingPx * 2
)
// Draw the white background
drawRect(
color = backgroundColor,
size = size
)
// Draw the original painter with the padding
with(painter) {
// Translate to apply padding
translate(left = paddingPx, top = paddingPx) {
draw(size = paddedSize)
}
}
}
}
@Composable
fun QRCodeView(
data: String,
) {
val qrCodeColor = if (isSystemInDarkTheme()) {
Color.White
} else {
Color.Black
}
val logoPainter = QRCodeBackgroundPainter(
painter = painterResource(
Res.drawable.compose_multiplatform
),
)
val painter = rememberQrCodePainter(
data = data,
) {
logo {
painter = logoPainter
padding = QrLogoPadding.Natural(.1f)
shape = QrLogoShape.Default
size = 0.3f
}
colors {
dark = QrBrush.solid(
color = qrCodeColor,
)
}
}
Column(
modifier = Modifier.fillMaxWidth(),
) {
Image(
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
painter = painter,
contentDescription = data,
)
}
}

View File

@@ -0,0 +1,69 @@
package at.torch.compose.ui.view.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
class ActiveProfileViewModel(
val activeUserPublicKey: HexKey,
val nostrEventId: HexKey,
initialActiveProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState,
val nostrRepository: at.torch.compose.repository.NostrRepository
): ViewModel() {
var activeProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState by mutableStateOf(initialActiveProfileUIState)
private set
fun retryLoad() {
activeProfileUIState = at.torch.compose.ui.view.state.ActiveProfileUIState.Loading
loadNostrFeed()
}
fun loadNostrFeed() {
viewModelScope.launch(Dispatchers.IO) {
// Get contact list and sync feed...
nostrRepository.observeLocalNostrEventById(
nostrEventId
).firstOrNull().let { localNostrEvent ->
activeProfileUIState = if (localNostrEvent != null && localNostrEvent.nostrEvent.kind == MetadataEvent.KIND) {
at.torch.compose.ui.view.state.ActiveProfileUIState.Loaded(
localNostrEvent = localNostrEvent
)
} else {
at.torch.compose.ui.view.state.ActiveProfileUIState.Error
}
}
}
}
companion object {
const val TAG = "ActiveProfileViewModel"
fun factory(
nostrEventId: HexKey,
activeUserPublicKey: HexKey,
initialActiveProfileUIState: at.torch.compose.ui.view.state.ActiveProfileUIState = at.torch.compose.ui.view.state.ActiveProfileUIState.Loading,
nostrRepository: at.torch.compose.repository.NostrRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ActiveProfileViewModel(
nostrEventId = nostrEventId,
initialActiveProfileUIState = initialActiveProfileUIState,
nostrRepository = nostrRepository,
activeUserPublicKey = activeUserPublicKey
)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package at.torch.compose.ui.view.state
sealed interface ActiveProfileUIState {
data class Loaded(
val localNostrEvent: at.torch.compose.database.model.intermdiate.LocalNostrEvent
): ActiveProfileUIState
data object Error: ActiveProfileUIState
data object NotFound: ActiveProfileUIState
data object Loading: ActiveProfileUIState
}

View File

@@ -29,6 +29,7 @@ navigationCompose = "2.9.2"
okhttp = "5.3.2"
okio = "3.17.0"
pagingCommon = "3.5.0"
qrose = "1.1.2"
quartz = "1.10.0"
room3 = "3.0.0-alpha06"
sqldelight = "2.3.2"
@@ -79,6 +80,7 @@ lightning-kmp-core = { module = "fr.acinq.lightning:lightning-kmp-core", version
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }