From f8e4a5b158d249080929aa77f5e02922ae5c86f7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 01:28:30 +0200 Subject: [PATCH] Add the add-chapter flow with paragraph chunking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a chapter (markdown original text) to an artifact from its detail screen, deriving word/character counts from the text and splitting it into paragraph chunks. - MantraRepository.addChapter attaches the chapter to the artifact's latest version (index = existing chapter count), computes word/character counts, and persists the MantraChapter plus a MarmotInnerEvent rumor (kind = ChapterEvent.KIND). It then splits the markdown by paragraph into MantraChunks, each with its own index/counts and a rumor (kind = ChunkEvent.KIND). New DAO query getChaptersByArtifactVersionId. - New press.mantra.compose.text.Markdown helpers: wordCount, characterCount, splitParagraphs (blank-line separated). - ChapterEvent.build / ChunkEvent.build now take the real fields (fixing the phantom generics); toChapterEvent / toChunkEvent tag order matches build and fromChapterEventTemplate / fromChunkEventTemplate are added so the event ids round-trip, mirroring the other models. - New AddChapterScreen (route + ViewModel + UIState) with a name field and a markdown text field plus a live "words · characters · chunks" preview, reached from an Add Chapter button in ArtifactDetailScreen (disabled until the artifact has a version). On success it lands on a freshly-loaded artifact detail via popUpTo. Co-Authored-By: Claude Opus 4.8 --- .../compose/database/dao/MantraChapterDao.kt | 3 + .../compose/database/model/MantraChapter.kt | 32 ++- .../compose/database/model/MantraChunk.kt | 29 +++ .../repository/DatabaseMantraRepository.kt | 85 +++++++ .../compose/nostr/nip30303/ChapterEvent.kt | 16 +- .../compose/nostr/nip30303/ChunkEvent.kt | 13 +- .../compose/repository/MantraRepository.kt | 22 ++ .../press/mantra/compose/text/Markdown.kt | 30 +++ .../compose/ui/composable/AddChapterScreen.kt | 222 ++++++++++++++++++ .../ui/composable/ArtifactDetailScreen.kt | 30 +++ .../ui/composable/navigation/MantraNavHost.kt | 30 +++ .../navigation/routes/AddChapterRoute.kt | 11 + .../ui/view/model/AddChapterViewModel.kt | 115 +++++++++ .../ui/view/state/AddChapterUIState.kt | 15 ++ 14 files changed, 645 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddChapterRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChapterDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChapterDao.kt index 7e0aa5d9..02695c17 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChapterDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChapterDao.kt @@ -23,4 +23,7 @@ interface MantraChapterDao { """ ) suspend fun getChaptersByArtifactId(artifactId: String): List + + @Query("SELECT * FROM MantraChapter WHERE artifactVersionId = :artifactVersionId ORDER BY `index` ASC") + suspend fun getChaptersByArtifactVersionId(artifactVersionId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt index 78bc7b8f..5455a4fd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChapter.kt @@ -5,9 +5,11 @@ import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity -import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag @@ -71,7 +73,9 @@ data class MantraChapter( id = id, pubKey = publicKey, createdAt = createdAt.epochSeconds, - tags = TagArrayBuilder() + // Tag order matches ChapterEvent.build so the event id round-trips. + tags = TagArrayBuilder() + .addUnique(AltTag.assemble(ChapterEvent.ALT_DESCRIPTION)) .addUnique( ArtifactVersionIdTag.assemble(artifactVersionId) ) @@ -92,6 +96,30 @@ data class MantraChapter( } companion object { + fun fromChapterEventTemplate( + chapterEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraChapter? { + return fromChapterEvent( + ChapterEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = chapterEventTemplate.tags, + content = chapterEventTemplate.content, + createdAt = chapterEventTemplate.createdAt, + kind = chapterEventTemplate.kind, + ), + content = chapterEventTemplate.content, + tags = chapterEventTemplate.tags, + createdAt = chapterEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + fun fromChapterEvent(chapterEvent: ChapterEvent, chatRoomId: HexKey): MantraChapter? { return chapterEvent.name()?.let { name -> chapterEvent.originalText()?.let { originalText -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt index c16c9aaf..707e1b00 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt @@ -5,6 +5,9 @@ import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -66,7 +69,9 @@ data class MantraChunk( id = id, pubKey = publicKey, createdAt = createdAt.epochSeconds, + // Tag order matches ChunkEvent.build so the event id round-trips. tags = TagArrayBuilder() + .addUnique(AltTag.assemble(ChunkEvent.ALT_DESCRIPTION)) .addUnique( ChapterIdTag.assemble(chapterId) ) @@ -85,6 +90,30 @@ data class MantraChunk( } companion object { + fun fromChunkEventTemplate( + chunkEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraChunk? { + return fromChunkEvent( + ChunkEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = chunkEventTemplate.tags, + content = chunkEventTemplate.content, + createdAt = chunkEventTemplate.createdAt, + kind = chunkEventTemplate.kind, + ), + content = chunkEventTemplate.content, + tags = chunkEventTemplate.tags, + createdAt = chunkEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + fun fromChunkEvent(chunkEvent: ChunkEvent, chatRoomId: HexKey): MantraChunk? { return chunkEvent.wordStatisticsTag?.let { wordStatisticsTag -> chunkEvent.index()?.let { index -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 8121b3d6..6f87504a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -7,14 +7,18 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter +import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown class DatabaseMantraRepository( val database: MantraDatabase, @@ -38,6 +42,87 @@ class DatabaseMantraRepository( override suspend fun getTranslationsForArtifact(artifactId: String): List = database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId) + override suspend fun addChapter( + artifactId: String, + name: String, + originalText: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraChapter? { + // Chapters attach to an artifact version; use the latest one. + val version = database.mantraArtifactVersionDao() + .getArtifactVersionsByArtifactId(artifactId) + .firstOrNull() ?: return null + + val chapterIndex = database.mantraChapterDao() + .getChaptersByArtifactVersionId(version.id) + .size + + val chapterEventTemplate = ChapterEvent.build( + artifactVersionId = version.id, + name = name, + originalText = originalText, + index = chapterIndex, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ) + + val chapter = MantraChapter.fromChapterEventTemplate( + chapterEventTemplate = chapterEventTemplate, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + ) ?: return null + + return try { + database.mantraChapterDao().upsert(chapter) + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = chapter.id, + publicKey = chapter.publicKey, + kind = ChapterEvent.KIND, + createdAt = chapter.createdAt, + tags = chapterEventTemplate.tags, + content = chapterEventTemplate.content, + chatRoomId = chapter.chatRoomId, + ) + ) + + // Split the markdown into paragraph chunks. + Markdown.splitParagraphs(originalText).forEachIndexed { chunkIndex, paragraph -> + val chunkEventTemplate = ChunkEvent.build( + chapterId = chapter.id, + text = paragraph, + index = chunkIndex, + wordCount = Markdown.wordCount(paragraph), + characterCount = Markdown.characterCount(paragraph), + ) + MantraChunk.fromChunkEventTemplate( + chunkEventTemplate = chunkEventTemplate, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + )?.let { chunk -> + database.mantraChunkDao().upsert(chunk) + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = chunk.id, + publicKey = chunk.publicKey, + kind = ChunkEvent.KIND, + createdAt = chunk.createdAt, + tags = chunkEventTemplate.tags, + content = chunkEventTemplate.content, + chatRoomId = chunk.chatRoomId, + ) + ) + } + } + + chapter + } catch (error: Throwable) { + logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error) + null + } + } + override suspend fun getDialects(chatRoomId: String): List = database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt index cba604d8..371a4341 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt @@ -40,11 +40,21 @@ class ChapterEvent( const val ALT_DESCRIPTION = "Chapter" fun build( - content: String, + artifactVersionId: String, + name: String, + originalText: String, + index: Int, + wordCount: Int, + characterCount: Int, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { alt(ALT_DESCRIPTION) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + addUnique(NameTag.assemble(name)) + addUnique(OriginalTextTag.assemble(originalText)) + addUnique(IndexTag.assemble(index)) + addUnique(WordStatisticsTag.assemble(wordCount, characterCount)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt index e90486b9..35aa37c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt @@ -37,11 +37,18 @@ class ChunkEvent( const val ALT_DESCRIPTION = "Chunk" fun build( - content: String, + chapterId: String, + text: String, + index: Int, + wordCount: Int, + characterCount: Int, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, text, createdAt) { alt(ALT_DESCRIPTION) + addUnique(ChapterIdTag.assemble(chapterId)) + addUnique(IndexTag.assemble(index)) + addUnique(WordStatisticsTag.assemble(wordCount, characterCount)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 65597fff..23d98928 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -19,6 +19,20 @@ interface MantraRepository { suspend fun getTranslationsForArtifact(artifactId: String): List + /** + * Add a chapter (markdown [originalText]) to the artifact's latest version. + * Word/character counts are derived from the text, and the chapter is split + * by paragraph into associated chunks. Returns null when the artifact has no + * version to attach to. + */ + suspend fun addChapter( + artifactId: String, + name: String, + originalText: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraChapter? + suspend fun getDialects(chatRoomId: String): List suspend fun addDialect( @@ -62,6 +76,14 @@ interface MantraRepository { override suspend fun getTranslationsForArtifact(artifactId: String): List = emptyList() + override suspend fun addChapter( + artifactId: String, + name: String, + originalText: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraChapter? = null + override suspend fun getDialects(chatRoomId: String): List = emptyList() override suspend fun addDialect( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt new file mode 100644 index 00000000..5771c2dd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt @@ -0,0 +1,30 @@ +package press.mantra.compose.text + +/** + * Lightweight helpers for the markdown original text of chapters/chunks. + * + * Counts are computed on the raw markdown text (no rendering/stripping): + * words are whitespace-separated tokens and characters are the string length. + */ +object Markdown { + private val WHITESPACE = Regex("\\s+") + + /** A paragraph break is one or more blank lines. */ + private val PARAGRAPH_BREAK = Regex("(?:\\r?\\n){2,}") + + fun wordCount(text: String): Int { + val trimmed = text.trim() + return if (trimmed.isEmpty()) 0 else trimmed.split(WHITESPACE).count { it.isNotBlank() } + } + + fun characterCount(text: String): Int = text.length + + /** + * Split markdown into paragraphs (blocks separated by blank lines), + * trimmed and with empties removed. Each paragraph becomes a chunk. + */ + fun splitParagraphs(text: String): List = + text.split(PARAGRAPH_BREAK) + .map { it.trim() } + .filter { it.isNotEmpty() } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt new file mode 100644 index 00000000..221269df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -0,0 +1,222 @@ +package press.mantra.compose.ui.composable + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material3.BottomAppBar +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 +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.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.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.AddChapterViewModel +import press.mantra.compose.ui.view.state.AddChapterUIState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddChapterScreen( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading, + mantraRepository: MantraRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addChapterViewModel: AddChapterViewModel = viewModel( + factory = AddChapterViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddChapterUIState = initialAddChapterUIState, + mantraRepository = mantraRepository, + ) + ) + + when (val addChapterUIState = addChapterViewModel.addChapterUIState) { + is AddChapterUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text(text = addChapterUIState.message) + } + } + + is AddChapterUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val originalTextFieldState = rememberTextFieldState() + + // Live counts / paragraph (chunk) preview from the markdown text. + val originalText = originalTextFieldState.text.toString() + val wordCount = Markdown.wordCount(originalText) + val characterCount = Markdown.characterCount(originalText) + val paragraphCount = Markdown.splitParagraphs(originalText).size + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add chapter to ${addChapterUIState.artifact.name}") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + addChapterViewModel.addChapter( + nameField = nameFieldState, + originalTextField = originalTextFieldState, + onSuccess = { + onNavigateToRoute.invoke( + ArtifactDetailRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + }, + onFailure = {} + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add chapter" + ) + Text("Add Chapter") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = nameFieldState, + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = "Name of the chapter" + ) + }, + label = { Text("Chapter Name") }, + placeholder = { Text("eg. Chapter 1 — The Beginning") }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f), + state = originalTextFieldState, + label = { Text("Original text (markdown)") }, + placeholder = { Text("Paste the chapter's markdown. Blank lines separate paragraphs into chunks.") }, + ) + + Text( + text = "$wordCount words · $characterCount characters · $paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"}", + style = MaterialTheme.typography.labelMedium + ) + } + } + } + + AddChapterUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Add Chapter", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + + LaunchedEffect(true) { + if (initialAddChapterUIState == AddChapterUIState.Loading) { + addChapterViewModel.initiateAddChapter() + } + } +} + +@Preview +@Composable +private fun AddChapterScreenPreview() { + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddChapterScreen( + activeUserPublicKey = "", + artifactId = "artifactId", + chatRoomId = "chatRoomId", + relayHint = null, + initialAddChapterUIState = AddChapterUIState.Loaded( + artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "chatRoomId", + signature = "" + ) + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 1e15a49f..bf04f888 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -9,9 +9,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,6 +25,7 @@ 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 @@ -35,6 +38,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute +import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ArtifactDetailViewModel @@ -49,6 +54,7 @@ fun ArtifactDetailScreen( relayHint: String?, initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading, mantraRepository: MantraRepository, + onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { val artifactDetailViewModel: ArtifactDetailViewModel = viewModel( @@ -170,6 +176,29 @@ fun ArtifactDetailScreen( } } + item { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + AddChapterRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.PostAdd, + contentDescription = "Add chapter" + ) + Spacer(modifier = Modifier.width(10.dp)) + Text("Add Chapter") + } + } + item { HorizontalDivider() } // Translations @@ -261,6 +290,7 @@ private fun ArtifactDetailScreenPreview() { ) ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, onNavigateBack = {} ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9b0d521b..d0cb8f5d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -89,8 +89,10 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute +import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen +import press.mantra.compose.ui.composable.AddChapterScreen @Composable fun MantraNavHost( @@ -686,6 +688,34 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddChapterScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + // Replace both this add screen and the stale artifact detail + // beneath it so we land on a freshly-loaded detail screen. + navController.navigate(route = actionRoute) { + popUpTo { + inclusive = true + } + } + }, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddChapterRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddChapterRoute.kt new file mode 100644 index 00000000..e16c8d0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddChapterRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddChapterRoute( + val activeUserPublicKey: String, + val artifactId: String, + val chatRoomId: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt new file mode 100644 index 00000000..0e30808c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -0,0 +1,115 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +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 co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.AddChapterUIState + +class AddChapterViewModel( + val artifactId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddChapterUIState: AddChapterUIState, + val mantraRepository: MantraRepository, +): ViewModel() { + + var addChapterUIState: AddChapterUIState by mutableStateOf(initialAddChapterUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddChapter() { + viewModelScope.launch(Dispatchers.IO) { + val artifact = mantraRepository.getArtifact(artifactId) + addChapterUIState = if (artifact == null) { + AddChapterUIState.Error("Couldn't find the artifact") + } else { + AddChapterUIState.Loaded(artifact = artifact) + } + } + } + + fun addChapter( + nameField: TextFieldState, + originalTextField: TextFieldState, + onSuccess: () -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val originalText = originalTextField.text.toString() + + if (name.isBlank() || originalText.isBlank()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val chapter = runCatching { + mantraRepository.addChapter( + artifactId = artifactId, + name = name, + originalText = originalText, + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey, + ) + }.onFailure { error -> + logger.e("Failed to add chapter", error) + }.getOrNull() + + if (chapter != null) { + nameField.clearText() + originalTextField.clearText() + onSuccess.invoke() + } else { + onFailure.invoke() + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddChapterViewModel" + + fun factory( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialAddChapterUIState: AddChapterUIState = AddChapterUIState.Loading, + mantraRepository: MantraRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddChapterViewModel( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddChapterUIState = initialAddChapterUIState, + mantraRepository = mantraRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt new file mode 100644 index 00000000..02c798d4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddChapterUIState.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifact + +sealed interface AddChapterUIState { + data class Loaded( + val artifact: MantraArtifact + ): AddChapterUIState + + data class Error( + val message: String + ): AddChapterUIState + + data object Loading: AddChapterUIState +}