From 0957f710d986791448c5034087ec09d628287823 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 1 Jul 2026 15:23:48 +0200 Subject: [PATCH] Add share QR code --- .../ui/composable/ActiveProfileScreen.kt | 6 +- .../ui/composable/ShareProfileScreen.kt | 225 ++++++++++++++++++ .../ui/composable/navigation/TorchNavHost.kt | 21 +- .../navigation/routes/ShareProfileRoute.kt | 9 + .../ui/view/model/ShareProfileViewModel.kt | 70 ++++++ .../ui/view/state/ShareProfileUIState.kt | 12 + 6 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ShareProfileScreen.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ShareProfileRoute.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ShareProfileViewModel.kt create mode 100755 composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ShareProfileUIState.kt diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt index 4542e5ee..b1793dca 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ActiveProfileScreen.kt @@ -43,6 +43,7 @@ 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.navigation.routes.ShareProfileRoute 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 @@ -185,7 +186,10 @@ fun ActiveProfileScreen( Button( onClick = { onNavigateToRoute.invoke( - ImplementationPendingRoute("Share and Connect") + ShareProfileRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = nostrEventId + ) ) }, ) { diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ShareProfileScreen.kt new file mode 100644 index 00000000..540966a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ShareProfileScreen.kt @@ -0,0 +1,225 @@ +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.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.QRCodeView +import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar +import at.torch.compose.ui.view.model.ShareProfileViewModel +import at.torch.compose.ui.view.state.ShareProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ShareProfileScreen( + initialShareProfileUIState: ShareProfileUIState = ShareProfileUIState.Loading, + activeUserPublicKey: HexKey, + nostrEventId: HexKey, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, + nostrRepository: NostrRepository, +) { + + val shareProfileViewModel: ShareProfileViewModel = viewModel( + factory = ShareProfileViewModel.factory( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialShareProfileUIState, + nostrRepository = nostrRepository, + activeUserPublicKey = activeUserPublicKey + ), + ) + + when(val shareProfileUIState = shareProfileViewModel.shareProfileUIState) { + ShareProfileUIState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Something went wrong") + } + } + is ShareProfileUIState.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 = shareProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey + ) + + Column { + shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + shareProfileUIState.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.padding(40.dp) + ) { + QRCodeView( + activeUserPublicKey.hexToNpubHrp() + ) + } + + + } + } + } + } + } + ShareProfileUIState.Loading -> { + LoadingDataIndicator() + + LaunchedEffect(true) { + shareProfileViewModel.loadNostrFeed() + } + } + ShareProfileUIState.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() + ) { + ShareProfileScreen( + initialShareProfileUIState = ShareProfileUIState.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 = {} + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt index 4ffe8c8b..f35e5bdb 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/TorchNavHost.kt @@ -18,11 +18,13 @@ 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.ShareProfileScreen 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.ShareProfileRoute 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 @@ -448,7 +450,7 @@ fun TorchNavHost( } } composable { backStackEntry -> - val route = backStackEntry.toRoute() + val route = backStackEntry.toRoute() ActiveProfileScreen( activeUserPublicKey = route.activeUserPublicKey, @@ -464,6 +466,23 @@ fun TorchNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ShareProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + nostrRepository = databaseNostrRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ShareProfileRoute.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ShareProfileRoute.kt new file mode 100755 index 00000000..88b1ccbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/navigation/routes/ShareProfileRoute.kt @@ -0,0 +1,9 @@ +package at.torch.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ShareProfileRoute( + val activeUserPublicKey: String, + val nostrEventId: String, +): Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ShareProfileViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ShareProfileViewModel.kt new file mode 100755 index 00000000..84c6ed22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ShareProfileViewModel.kt @@ -0,0 +1,70 @@ +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 at.torch.compose.ui.view.state.ShareProfileUIState +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 ShareProfileViewModel( + val activeUserPublicKey: HexKey, + val nostrEventId: HexKey, + initialActiveProfileUIState: ShareProfileUIState, + val nostrRepository: at.torch.compose.repository.NostrRepository +): ViewModel() { + var shareProfileUIState: ShareProfileUIState by mutableStateOf(initialActiveProfileUIState) + private set + + fun retryLoad() { + shareProfileUIState = ShareProfileUIState.Loading + loadNostrFeed() + } + + fun loadNostrFeed() { + viewModelScope.launch(Dispatchers.IO) { + + // Get contact list and sync feed... + nostrRepository.observeLocalNostrEventById( + nostrEventId + ).firstOrNull().let { localNostrEvent -> + shareProfileUIState = if (localNostrEvent != null && localNostrEvent.nostrEvent.kind == MetadataEvent.KIND) { + ShareProfileUIState.Loaded( + localNostrEvent = localNostrEvent + ) + } else { + ShareProfileUIState.Error + } + } + } + } + + companion object { + const val TAG = "ShareProfileViewModel" + + fun factory( + nostrEventId: HexKey, + activeUserPublicKey: HexKey, + initialActiveProfileUIState: ShareProfileUIState = ShareProfileUIState.Loading, + nostrRepository: at.torch.compose.repository.NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ShareProfileViewModel( + nostrEventId = nostrEventId, + initialActiveProfileUIState = initialActiveProfileUIState, + nostrRepository = nostrRepository, + activeUserPublicKey = activeUserPublicKey + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ShareProfileUIState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ShareProfileUIState.kt new file mode 100755 index 00000000..22c55803 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ShareProfileUIState.kt @@ -0,0 +1,12 @@ +package at.torch.compose.ui.view.state + +sealed interface ShareProfileUIState { + data class Loaded( + val localNostrEvent: at.torch.compose.database.model.intermdiate.LocalNostrEvent + ): ShareProfileUIState + + data object Error: ShareProfileUIState + data object NotFound: ShareProfileUIState + data object Loading: ShareProfileUIState +} +