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:
Kgothatso Ngako
2026-07-26 01:28:30 +02:00
parent d671c7c300
commit f8e4a5b158
14 changed files with 645 additions and 8 deletions

View File

@@ -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>
}

View File

@@ -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 ->

View File

@@ -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 ->

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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(

View File

@@ -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() }
}

View File

@@ -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 = {}
)
}
}
}

View File

@@ -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 = {}
)
}

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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,
)
}
}
}
}

View File

@@ -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
}