Add write note functionality

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

No files matched your search

@@ -12,6 +12,7 @@ import ac.aux.compose.repository.NostrRepository
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant import kotlin.time.Instant
@@ -59,8 +60,6 @@ class DatabaseNostrRepository(
) )
) )
if (unsignedNostrEvent == null) { if (unsignedNostrEvent == null) {
onError.invoke() onError.invoke()
} else if (unsignedNostrEvent.kind == 0 ) { } 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 { override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? = try {
delay(2_100) // Just to make it look like we are doing serious work... delay(2_100) // Just to make it look like we are doing serious work...
unsignedNostrEvent.copy( unsignedNostrEvent.copy(
@@ -27,6 +27,13 @@ interface NostrRepository {
onProfileReady: (Profile) -> Unit onProfileReady: (Profile) -> Unit
) )
suspend fun createNewTextNote(
publicKey: HexKey,
text: String,
onError: () -> Unit,
onSuccess: () -> Unit
)
suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent?
suspend fun wipeDatabase() 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? { override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
return null return null
} }
@@ -14,8 +14,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -40,7 +44,8 @@ import kotlin.time.Clock
@Composable @Composable
fun FeedScreen( fun FeedScreen(
initialFeedListUIState: FeedListUIState, initialFeedListUIState: FeedListUIState,
onNavigateToEvent: (Event) -> Unit onNavigateToEvent: (Event) -> Unit,
onNavigateToWriteNewNote: () -> Unit,
) { ) {
val feedListViewModel: FeedListViewModel = viewModel( val feedListViewModel: FeedListViewModel = viewModel(
factory = FeedListViewModel.factory( factory = FeedListViewModel.factory(
@@ -80,6 +85,20 @@ fun FeedScreen(
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNavigateToWriteNewNote,
icon = {
Icon(
Icons.Default.Create,
contentDescription = "Write"
)
},
text = {
Text("Write")
}
)
} }
) { innerPadding -> ) { innerPadding ->
Column( Column(
@@ -125,7 +144,8 @@ private fun FeedScreenPreview() {
) { ) {
FeedScreen( FeedScreen(
initialFeedListUIState = FeedListUIState.Loading, initialFeedListUIState = FeedListUIState.Loading,
onNavigateToEvent = {} onNavigateToEvent = {},
onNavigateToWriteNewNote = {}
) )
} }
} }
@@ -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)
}
}
}
@@ -13,6 +13,7 @@ import ac.aux.compose.ui.composable.UnannouncedProfileScreen
import ac.aux.compose.ui.composable.UnindexedProfileScreen import ac.aux.compose.ui.composable.UnindexedProfileScreen
import ac.aux.compose.ui.composable.UnqueuedProfileScreen import ac.aux.compose.ui.composable.UnqueuedProfileScreen
import ac.aux.compose.ui.composable.UnsignedProfileScreen 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.BlankRoute
import ac.aux.compose.ui.composable.navigation.routes.CreateProfileRoute import ac.aux.compose.ui.composable.navigation.routes.CreateProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.FeedRoute 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.UnindexedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnqueuedProfileRoute 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.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.composable.widgets.feed.DUMMY_EVENTS
import ac.aux.compose.ui.view.model.NavigationViewModel import ac.aux.compose.ui.view.model.NavigationViewModel
import ac.aux.compose.ui.view.state.FeedListUIState import ac.aux.compose.ui.view.state.FeedListUIState
@@ -47,6 +49,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable @Composable
fun AuxNavHost( fun AuxNavHost(
@@ -68,6 +72,7 @@ fun AuxNavHost(
} }
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
val applicationMainScope = CoroutineScope(Dispatchers.Main)
val navigationViewModel: NavigationViewModel = viewModel ( val navigationViewModel: NavigationViewModel = viewModel (
@@ -197,6 +202,16 @@ fun AuxNavHost(
nostrRepository = nostrRepository nostrRepository = nostrRepository
) )
} }
composable< WriteNewNoteRoute> {
WriteNewNoteScreen(
onNostrEventPublished = {
applicationMainScope.launch {
navController.popBackStack()
}
},
nostrRepository = nostrRepository
)
}
composable<UnsignedProfileRoute> { backStackEntry -> composable<UnsignedProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnsignedProfileRoute>() val route = backStackEntry.toRoute<UnsignedProfileRoute>()
@@ -247,6 +262,11 @@ fun AuxNavHost(
event.content event.content
) )
) )
},
onNavigateToWriteNewNote = {
navController.navigate(
route = WriteNewNoteRoute()
)
} }
) )
} }
@@ -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() {
}
@@ -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
}
)
}
}
}
@@ -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()
}
@@ -0,0 +1,5 @@
package ac.aux.compose.ui.view.state.form
data class WriteNewNoteFormState(
val textField: TextField = TextField(),
)