Add sign in screen

This commit is contained in:
Kgothatso Ngako
2026-03-29 03:29:50 +02:00
parent a9d8151728
commit 0ce00ae456
8 changed files with 451 additions and 8 deletions

View File

@@ -15,6 +15,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlin.time.Clock
import kotlin.time.Instant
class DatabaseNostrRepository(
@@ -40,8 +41,8 @@ class DatabaseNostrRepository(
override suspend fun createNewProfile(
publicKey: HexKey,
name: String,
biography: String,
name: String?,
biography: String?,
onError: () -> Unit,
onProfileReady: (Profile) -> Unit
) {
@@ -50,13 +51,21 @@ class DatabaseNostrRepository(
about = biography,
)
val signedAt = if (name == null && biography == null) {
// We are most likely try to sign in to an already existing block...
Instant.fromEpochMilliseconds(1231006505000L)
} else {
null
}
val unsignedNostrEvent = saveUnsignedNostrEvent(
UnsignedNostrEvent(
pubKey = publicKey,
kind = profileEventTemplate.kind,
createdAt = Instant.fromEpochMilliseconds(profileEventTemplate.createdAt),
tags = profileEventTemplate.tags,
content = profileEventTemplate.content
content = profileEventTemplate.content,
signedAt = signedAt // This will keep us from trying to sign this event... but we should automatically sync the npub profile
)
)

View File

@@ -19,8 +19,8 @@ interface NostrRepository {
suspend fun createNewProfile(
publicKey: HexKey,
name: String,
biography: String,
name: String?,
biography: String?,
onError: () -> Unit,
// onUnsignProfile: (UnsignedNostrEvent) -> Unit,
// onUnbroadcastedProfile: (NostrEvent) -> Unit,
@@ -64,8 +64,8 @@ interface NostrRepository {
override suspend fun createNewProfile(
publicKey: HexKey,
name: String,
biography: String,
name: String?,
biography: String?,
onError: () -> Unit,
onProfileReady: (Profile) -> Unit
) {

View File

@@ -0,0 +1,293 @@
package ac.aux.compose.ui.composable
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.theme.AuxTheme
import ac.aux.compose.ui.view.model.SignInToProfileViewModel
import ac.aux.compose.ui.view.state.SignInToProfileUIState
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SignInToProfileScreen(
initialSignInToProfileUIState: SignInToProfileUIState = SignInToProfileUIState.InputPrompt,
nostrRepository: NostrRepository
) {
val signInToProfileViewModel: SignInToProfileViewModel = viewModel (
factory = SignInToProfileViewModel.factory(
initialSignInToProfileUIState,
nostrRepository
)
)
Scaffold { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(
10.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
when (val signInToProfileUIState = signInToProfileViewModel.signInToProfileUIState.value) {
is SignInToProfileUIState.InputPrompt -> {
Text(
"Sign In",
style = MaterialTheme.typography.headlineSmall
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = signInToProfileViewModel.signInToProfileFormState.textField.text.value,
onValueChange = {
signInToProfileViewModel.signInToProfileFormState.textField.text.value = it
},
isError = signInToProfileViewModel.signInToProfileFormState.textField.errorMessage.value != null,
supportingText = signInToProfileViewModel.signInToProfileFormState.textField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
label = {
Text(
text = "nsec, npub, nip-05 (static address)",
maxLines = 1,
)
},
placeholder = {
Text(
text = "Enter the nsec or npub (read only) that you want to sign in as",
maxLines = 1,
)
},
leadingIcon = {
Icon(
Icons.Default.Create,
contentDescription = "Write note"
)
}
)
Button(
onClick = {
signInToProfileViewModel.signInToProfileUIState.value = signInToProfileViewModel.validateCredential().let { credentialEntity ->
when (credentialEntity) {
is NSec -> {
SignInToProfileUIState.ConfirmNsecSignIn(
nsec = signInToProfileViewModel.signInToProfileFormState.textField.text.value,
hexKey = credentialEntity.hex
)
}
is NPub -> {
SignInToProfileUIState.ConfirmNpubSignIn(
npub = signInToProfileViewModel.signInToProfileFormState.textField.text.value,
hexKey = credentialEntity.hex
)
}
else -> {
SignInToProfileUIState.Error
}
}
}
},
) {
Text(
"Next"
)
Icon(
Icons.Default.NavigateNext,
contentDescription = "Next"
)
}
}
is SignInToProfileUIState.ConfirmNpubSignIn-> {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Sign in to Npub",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = signInToProfileUIState.npub,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = signInToProfileUIState.hexKey,
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(4f)
)
Text(
text = "This will give you read only access to the profile.",
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
if (signInToProfileViewModel.isActionPending.value) {
LoadingIndicator(
modifier = Modifier
)
} else {
Button(
onClick = {
signInToProfileViewModel.signInToAccount(
onError = {
signInToProfileViewModel.signInToProfileUIState.value =
SignInToProfileUIState.Error
}
)
}
) {
Text(
text = "Sign In"
)
}
}
}
is SignInToProfileUIState.ConfirmNsecSignIn-> {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Sign in to nsec",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "Be sure to keep this nsec safe.",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
Spacer(
modifier = Modifier.weight(4f)
)
Text(
text = "This will give you write access to the profile.",
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
if (signInToProfileViewModel.isActionPending.value) {
LoadingIndicator(
modifier = Modifier
)
} else {
Button(
onClick = {
signInToProfileViewModel.signInToAccount(
onError = {
signInToProfileViewModel.signInToProfileUIState.value =
SignInToProfileUIState.Error
}
)
}
) {
Text(
text = "Sign In"
)
}
}
}
is SignInToProfileUIState.Error -> {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Something went wrong and we were unable to sign in to your provided profile. Please try again later.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
@Preview
@Composable
private fun SignInToProfileScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
SignInToProfileScreen(
initialSignInToProfileUIState =
SignInToProfileUIState.InputPrompt,
// SignInToProfileUIState.Error,
// SignInToProfileUIState.ConfirmNpubSignIn(
// npub = "npub1uh366qnj68a7atvwgwdy4569xd6v5jtferftapqztwjwlzh9553q47pqsx",
// hexKey = "e5e3ad0272d1fbeead8e439a4ad3453374ca4969c8d2be84025ba4ef8ae5a522"
// ),
// SignInToProfileUIState.ConfirmNsecSignIn(
// nsec = "nsecthingsaretobekeptsecret",
// hexKey = "e5e3ad0272d1fbeead8e439a4ad3453374ca4969c8d2be84025ba4ef8ae5a522"
// ),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY)
}
}
}

View File

@@ -8,6 +8,7 @@ import ac.aux.compose.ui.composable.FeedScreen
import ac.aux.compose.ui.composable.ImplementationPendingScreen
import ac.aux.compose.ui.composable.LandingScreen
import ac.aux.compose.ui.composable.LoadingScreen
import ac.aux.compose.ui.composable.SignInToProfileScreen
import ac.aux.compose.ui.composable.SocialPreconditionScreen
import ac.aux.compose.ui.composable.UnannouncedProfileScreen
import ac.aux.compose.ui.composable.UnindexedProfileScreen
@@ -20,6 +21,7 @@ import ac.aux.compose.ui.composable.navigation.routes.FeedRoute
import ac.aux.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import ac.aux.compose.ui.composable.navigation.routes.LandingRoute
import ac.aux.compose.ui.composable.navigation.routes.LoadingRoute
import ac.aux.compose.ui.composable.navigation.routes.SignInRoute
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -175,7 +177,7 @@ fun AuxNavHost(
},
onNavigateToSignIn = {
navController.navigate(
route = ImplementationPendingRoute("Sign in")
route = SignInRoute
)
},
onNavigateToCreateProfile = {
@@ -212,6 +214,11 @@ fun AuxNavHost(
nostrRepository = nostrRepository
)
}
composable<SignInRoute> {
SignInToProfileScreen(
nostrRepository = nostrRepository
)
}
composable<UnsignedProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnsignedProfileRoute>()

View File

@@ -0,0 +1,6 @@
package ac.aux.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
object SignInRoute: Route()

View File

@@ -0,0 +1,103 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.SignInToProfileUIState
import ac.aux.compose.ui.view.state.form.SignInToProfileFormState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class SignInToProfileViewModel(
initialWriteNewNoteUIState: SignInToProfileUIState,
val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(),
val nostrRepository: NostrRepository
): ViewModel() {
companion object {
private const val TAG = "SignInToProfileViewModel"
fun factory(
initialWriteNewNoteUIState: SignInToProfileUIState,
nostrRepository: NostrRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SignInToProfileViewModel(
initialWriteNewNoteUIState,
nostrRepository = nostrRepository
)
}
}
}
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
var signInToProfileUIState: MutableState<SignInToProfileUIState> = mutableStateOf(
initialWriteNewNoteUIState
)
fun validateCredential(): Entity? {
return signInToProfileFormState.textField.text.value.let { credentialText ->
if (credentialText.isBlank()) {
signInToProfileFormState.textField.errorMessage.value = "Please input a credential"
return null
} else {
signInToProfileFormState.textField.errorMessage.value = null
}
Nip19Parser.uriToRoute(signInToProfileFormState.textField.text.value)?.entity
}
}
fun signInToAccount(
onError: () -> Unit,
) {
logger.d { "signInToAccount" }
validateCredential().let { credentialEntity ->
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
when (credentialEntity) {
is NPub -> {
nostrRepository.createNewProfile(
publicKey = credentialEntity.hex,
name = null,
biography = null,
onError = onError,
onProfileReady = {}
)
}
is NSec -> {
nostrRepository.createNewProfile(
publicKey = credentialEntity.toPubKeyHex(),
name = null,
biography = null,
onError = onError,
onProfileReady = {}
)
}
else -> {
// TODO: Might want to give reasons for the error.
signInToProfileUIState.value = SignInToProfileUIState.Error
}
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package ac.aux.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
abstract class SignInToProfileUIState {
data object Error: SignInToProfileUIState()
data object InputPrompt: SignInToProfileUIState()
data class ConfirmNpubSignIn(
val npub: String,
val hexKey: HexKey
) : SignInToProfileUIState()
data class ConfirmNsecSignIn(
val nsec: String,
val hexKey: HexKey
) : SignInToProfileUIState()
}

View File

@@ -0,0 +1,5 @@
package ac.aux.compose.ui.view.state.form
data class SignInToProfileFormState(
val textField: TextField = TextField(),
)