Add the add-chapter flow with paragraph chunking
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<ArtifactDetailRoute>. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,4 +23,7 @@ interface MantraChapterDao {
|
||||
"""
|
||||
)
|
||||
suspend fun getChaptersByArtifactId(artifactId: String): List<MantraChapter>
|
||||
|
||||
@Query("SELECT * FROM MantraChapter WHERE artifactVersionId = :artifactVersionId ORDER BY `index` ASC")
|
||||
suspend fun getChaptersByArtifactVersionId(artifactVersionId: String): List<MantraChapter>
|
||||
}
|
||||
@@ -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<ArtifactVersionEvent>()
|
||||
// Tag order matches ChapterEvent.build so the event id round-trips.
|
||||
tags = TagArrayBuilder<ChapterEvent>()
|
||||
.addUnique(AltTag.assemble(ChapterEvent.ALT_DESCRIPTION))
|
||||
.addUnique(
|
||||
ArtifactVersionIdTag.assemble(artifactVersionId)
|
||||
)
|
||||
@@ -92,6 +96,30 @@ data class MantraChapter(
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromChapterEventTemplate(
|
||||
chapterEventTemplate: EventTemplate<ChapterEvent>,
|
||||
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 ->
|
||||
|
||||
@@ -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<ChunkEvent>()
|
||||
.addUnique(AltTag.assemble(ChunkEvent.ALT_DESCRIPTION))
|
||||
.addUnique(
|
||||
ChapterIdTag.assemble(chapterId)
|
||||
)
|
||||
@@ -85,6 +90,30 @@ data class MantraChunk(
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromChunkEventTemplate(
|
||||
chunkEventTemplate: EventTemplate<ChunkEvent>,
|
||||
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 ->
|
||||
|
||||
@@ -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<MantraTranslationArtifactVersion> =
|
||||
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<MantraDialect> =
|
||||
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
|
||||
|
||||
|
||||
@@ -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<ArtifactEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, content, createdAt) {
|
||||
initializer: TagArrayBuilder<ChapterEvent>.() -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArtifactEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, content, createdAt) {
|
||||
initializer: TagArrayBuilder<ChunkEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, text, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
addUnique(ChapterIdTag.assemble(chapterId))
|
||||
addUnique(IndexTag.assemble(index))
|
||||
addUnique(WordStatisticsTag.assemble(wordCount, characterCount))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,20 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
|
||||
|
||||
/**
|
||||
* 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<MantraDialect>
|
||||
|
||||
suspend fun addDialect(
|
||||
@@ -62,6 +76,14 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
|
||||
|
||||
override suspend fun addChapter(
|
||||
artifactId: String,
|
||||
name: String,
|
||||
originalText: String,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey,
|
||||
): MantraChapter? = null
|
||||
|
||||
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
|
||||
|
||||
override suspend fun addDialect(
|
||||
|
||||
@@ -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<String> =
|
||||
text.split(PARAGRAPH_BREAK)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AddChapterRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddChapterRoute>()
|
||||
|
||||
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<ArtifactDetailRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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<Boolean> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user