Add write note functionality

This commit is contained in:
Kgothatso Ngako
2026-03-29 02:06:55 +02:00
parent cf81f2d6ba
commit 817c3da8b3
9 changed files with 444 additions and 4 deletions

View File

@@ -12,6 +12,7 @@ import ac.aux.compose.repository.NostrRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.Instant
@@ -59,8 +60,6 @@ class DatabaseNostrRepository(
)
)
if (unsignedNostrEvent == null) {
onError.invoke()
} else if (unsignedNostrEvent.kind == 0 ) {
@@ -69,6 +68,33 @@ class DatabaseNostrRepository(
}
}
override suspend fun createNewTextNote(
publicKey: HexKey,
text: String,
onError: () -> Unit,
onSuccess: () -> Unit
) {
val textNote = TextNoteEvent.build(
note = text,
)
val unsignedNostrEvent = saveUnsignedNostrEvent(
UnsignedNostrEvent(
pubKey = publicKey,
kind = textNote.kind,
createdAt = Instant.fromEpochMilliseconds(textNote.createdAt),
tags = textNote.tags,
content = textNote.content
)
)
if (unsignedNostrEvent == null) {
onError.invoke()
} else {
onSuccess.invoke()
}
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? = try {
delay(2_100) // Just to make it look like we are doing serious work...
unsignedNostrEvent.copy(

View File

@@ -27,6 +27,13 @@ interface NostrRepository {
onProfileReady: (Profile) -> Unit
)
suspend fun createNewTextNote(
publicKey: HexKey,
text: String,
onError: () -> Unit,
onSuccess: () -> Unit
)
suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent?
suspend fun wipeDatabase()
@@ -64,6 +71,15 @@ interface NostrRepository {
) {
}
override suspend fun createNewTextNote(
publicKey: HexKey,
text: String,
onError: () -> Unit,
onSuccess: () -> Unit
) {
TODO("Not yet implemented")
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
return null
}

View File

@@ -14,8 +14,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Adb
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -40,7 +44,8 @@ import kotlin.time.Clock
@Composable
fun FeedScreen(
initialFeedListUIState: FeedListUIState,
onNavigateToEvent: (Event) -> Unit
onNavigateToEvent: (Event) -> Unit,
onNavigateToWriteNewNote: () -> Unit,
) {
val feedListViewModel: FeedListViewModel = viewModel(
factory = FeedListViewModel.factory(
@@ -80,6 +85,20 @@ fun FeedScreen(
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNavigateToWriteNewNote,
icon = {
Icon(
Icons.Default.Create,
contentDescription = "Write"
)
},
text = {
Text("Write")
}
)
}
) { innerPadding ->
Column(
@@ -125,7 +144,8 @@ private fun FeedScreenPreview() {
) {
FeedScreen(
initialFeedListUIState = FeedListUIState.Loading,
onNavigateToEvent = {}
onNavigateToEvent = {},
onNavigateToWriteNewNote = {}
)
}
}

View File

@@ -0,0 +1,246 @@
package ac.aux.compose.ui.composable
import ac.aux.compose.database.model.Profile
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.theme.AuxTheme
import ac.aux.compose.ui.theme.BluePill
import ac.aux.compose.ui.theme.RedPill
import ac.aux.compose.ui.view.model.WriteNewNoteViewModel
import ac.aux.compose.ui.view.state.CreateProfileUIState
import ac.aux.compose.ui.view.state.WriteNewNoteUIState
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.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.NavigateNext
import androidx.compose.material.icons.filled.NextPlan
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
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.graphics.Color
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun WriteNewNoteScreen(
initialWriteNewNoteUIState: WriteNewNoteUIState = WriteNewNoteUIState.InputPrompt,
onNostrEventPublished: () -> Unit,
nostrRepository: NostrRepository
) {
val writeNewNoteViewModel: WriteNewNoteViewModel = viewModel (
factory = WriteNewNoteViewModel.factory(
initialWriteNewNoteUIState,
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 writeNewNoteUIState = writeNewNoteViewModel.writeNewNoteUIState.value) {
is WriteNewNoteUIState.InputPrompt -> {
Text(
"Write new note",
style = MaterialTheme.typography.headlineSmall
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = writeNewNoteViewModel.writeNewNoteFormState.textField.text.value,
onValueChange = {
writeNewNoteViewModel.writeNewNoteFormState.textField.text.value = it
},
isError = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value != null,
supportingText = writeNewNoteViewModel.writeNewNoteFormState.textField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(
text = "What vibrations do you want to send out?",
maxLines = 1,
)
},
placeholder = {
Text(
text = "Enter the name you want to use for your profile",
maxLines = 1,
)
},
leadingIcon = {
Icon(
Icons.Default.Create,
contentDescription = "Write note"
)
}
)
Button(
onClick = {
writeNewNoteViewModel.validateInput().let { isInputValid ->
if (isInputValid) {
writeNewNoteViewModel.writeNewNoteUIState.value =
WriteNewNoteUIState.ConfirmNote(
text = writeNewNoteViewModel.writeNewNoteFormState.textField.text.value,
)
}
}
},
) {
Text(
"Next"
)
Icon(
Icons.Default.NavigateNext,
contentDescription = "Next"
)
}
}
is WriteNewNoteUIState.ConfirmNote-> {
// TODO: Get profile for active pubKey
Text(
text = "Display Name",
style = MaterialTheme.typography.labelLarge
)
Text(
text = writeNewNoteUIState.text,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "The above will be your new note.",
style = MaterialTheme.typography.bodySmall
)
if (writeNewNoteViewModel.isActionPending.value) {
LoadingIndicator(
modifier = Modifier
)
} else {
Button(
onClick = {
writeNewNoteViewModel.createNewNote(
onNostrEventPublished = onNostrEventPublished
)
}
) {
Text(
text = "Transmit Note"
)
}
}
}
is WriteNewNoteUIState.Error -> {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Something went wrong and we were unable to write a new note. Please try again later.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
@Preview
@Composable
private fun WriteNewNoteScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
WriteNewNoteScreen(
initialWriteNewNoteUIState =
WriteNewNoteUIState.InputPrompt,
// CreateProfileUIState.Error,
// CreateProfileUIState.ConfirmInput(
// name = "Alan Turing",
// bio = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// ),
// CreateProfileUIState.UnsignedProfile(
// unsignedNostrEvent = UnsignedNostrEvent(
// kind = 0,
// content = "Something here",
// tags = emptyArray(),
// ),
// name = "Alan Turing",
// bio = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// ),
// CreateProfileUIState.UnbroadcastedProfile(
// nostrEvent = NostrEvent(
// id = "",
// pubKey = "",
// kind = 0,
// content = "Something here",
// tags = emptyArray(),
// sig = ""
// ),
// name = "Alan Turing",
// bio = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// ),
onNostrEventPublished = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY)
}
}
}

View File

@@ -13,6 +13,7 @@ import ac.aux.compose.ui.composable.UnannouncedProfileScreen
import ac.aux.compose.ui.composable.UnindexedProfileScreen
import ac.aux.compose.ui.composable.UnqueuedProfileScreen
import ac.aux.compose.ui.composable.UnsignedProfileScreen
import ac.aux.compose.ui.composable.WriteNewNoteScreen
import ac.aux.compose.ui.composable.navigation.routes.BlankRoute
import ac.aux.compose.ui.composable.navigation.routes.CreateProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.FeedRoute
@@ -29,6 +30,7 @@ import ac.aux.compose.ui.composable.navigation.routes.UnannouncedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnindexedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnqueuedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnsignedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.WriteNewNoteRoute
import ac.aux.compose.ui.composable.widgets.feed.DUMMY_EVENTS
import ac.aux.compose.ui.view.model.NavigationViewModel
import ac.aux.compose.ui.view.state.FeedListUIState
@@ -47,6 +49,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun AuxNavHost(
@@ -68,6 +72,7 @@ fun AuxNavHost(
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
val applicationMainScope = CoroutineScope(Dispatchers.Main)
val navigationViewModel: NavigationViewModel = viewModel (
@@ -197,6 +202,16 @@ fun AuxNavHost(
nostrRepository = nostrRepository
)
}
composable< WriteNewNoteRoute> {
WriteNewNoteScreen(
onNostrEventPublished = {
applicationMainScope.launch {
navController.popBackStack()
}
},
nostrRepository = nostrRepository
)
}
composable<UnsignedProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnsignedProfileRoute>()
@@ -247,6 +262,11 @@ fun AuxNavHost(
event.content
)
)
},
onNavigateToWriteNewNote = {
navController.navigate(
route = WriteNewNoteRoute()
)
}
)
}

View File

@@ -0,0 +1,12 @@
package ac.aux.compose.ui.composable.navigation.routes
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Serializable
data class WriteNewNoteRoute(
val inReplyToEventId: String? = null,
): Route() {
}

View File

@@ -0,0 +1,79 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.WriteNewNoteUIState
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 ac.aux.compose.ui.view.state.form.WriteNewNoteFormState
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class WriteNewNoteViewModel(
val initialWriteNewNoteUIState: WriteNewNoteUIState,
val writeNewNoteFormState: WriteNewNoteFormState = WriteNewNoteFormState(),
val nostrRepository: NostrRepository
): ViewModel() {
companion object {
private const val TAG = "WriteNewNoteViewModel"
fun factory(
initialWriteNewNoteUIState: WriteNewNoteUIState,
nostrRepository: NostrRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
WriteNewNoteViewModel(
initialWriteNewNoteUIState,
nostrRepository = nostrRepository
)
}
}
}
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
var writeNewNoteUIState: MutableState<WriteNewNoteUIState> = mutableStateOf(
initialWriteNewNoteUIState
)
fun validateInput(): Boolean {
if (writeNewNoteFormState.textField.text.value.isBlank()) {
writeNewNoteFormState.textField.errorMessage.value = "Please input a note"
return false
} else {
writeNewNoteFormState.textField.errorMessage.value = null
}
return true
}
fun createNewNote(
onNostrEventPublished: () -> Unit
) {
logger.d { "createAccount" }
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.createNewTextNote(
publicKey = SeedManager.activePublicKey().toHexKey(),
text = writeNewNoteFormState.textField.text.value,
onSuccess = onNostrEventPublished,
onError = {
writeNewNoteUIState.value = WriteNewNoteUIState.Error
}
)
}
}
}

View File

@@ -0,0 +1,16 @@
package ac.aux.compose.ui.view.state
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
abstract class WriteNewNoteUIState {
data object Error: WriteNewNoteUIState()
data object InputPrompt: WriteNewNoteUIState()
data class ConfirmNote(
val text: String,
) : WriteNewNoteUIState()
}

View File

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