Add ChapterDetailScreen showing chapter text and chunks

Tapping a chapter in the artifact detail now opens a chapter detail screen.

- New ChapterDetailRoute + ChapterDetailScreen/ViewModel/UIState. The screen
  shows the chapter's index and word/character counts, its original markdown
  text (rendered as plain text — no markdown renderer available), and the
  chapter's paragraph chunks (index, text, counts) with an empty state.
- DAO queries getChapterById and getChunksByChapterId (validated by Room),
  exposed via MantraRepository.getChapter / getChunksForChapter (impl + NO_OP).
- ArtifactDetailScreen chapter cards are now clickable, navigating to
  ChapterDetailRoute; MantraNavHost registers the route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-07-26 01:34:12 +02:00
parent f8e4a5b158
commit b2664bf7a5
10 changed files with 362 additions and 1 deletions

View File

@@ -26,4 +26,7 @@ interface MantraChapterDao {
@Query("SELECT * FROM MantraChapter WHERE artifactVersionId = :artifactVersionId ORDER BY `index` ASC")
suspend fun getChaptersByArtifactVersionId(artifactVersionId: String): List<MantraChapter>
@Query("SELECT * FROM MantraChapter WHERE id = :id")
suspend fun getChapterById(id: String): MantraChapter?
}

View File

@@ -3,6 +3,7 @@ package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy.Companion.IGNORE
import androidx.room3.Query
import androidx.room3.Upsert
import press.mantra.compose.database.model.MantraChunk
@@ -10,4 +11,7 @@ import press.mantra.compose.database.model.MantraChunk
interface MantraChunkDao {
@Upsert
suspend fun upsert(mantraChunk: MantraChunk)
@Query("SELECT * FROM MantraChunk WHERE chapterId = :chapterId ORDER BY `index` ASC")
suspend fun getChunksByChapterId(chapterId: String): List<MantraChunk>
}

View File

@@ -39,6 +39,12 @@ class DatabaseMantraRepository(
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
override suspend fun getChapter(id: String): MantraChapter? =
database.mantraChapterDao().getChapterById(id)
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> =
database.mantraChunkDao().getChunksByChapterId(chapterId)
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
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
@@ -17,6 +18,10 @@ interface MantraRepository {
suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter>
suspend fun getChapter(id: String): MantraChapter?
suspend fun getChunksForChapter(chapterId: String): List<MantraChunk>
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
/**
@@ -74,6 +79,10 @@ interface MantraRepository {
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> = emptyList()
override suspend fun getChapter(id: String): MantraChapter? = null
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> = emptyList()
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun addChapter(

View File

@@ -39,6 +39,7 @@ 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.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -158,7 +159,18 @@ fun ArtifactDetailScreen(
items = artifactDetailUIState.chapters,
key = { chapter -> chapter.id }
) { chapter ->
Card {
Card(
onClick = {
onNavigateToRoute.invoke(
ChapterDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chapterId = chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(

View File

@@ -0,0 +1,211 @@
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Segment
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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.MantraChapter
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ChapterDetailViewModel
import press.mantra.compose.ui.view.state.ChapterDetailUIState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChapterDetailScreen(
activeUserPublicKey: HexKey,
chapterId: String,
chatRoomId: String,
relayHint: String?,
initialChapterDetailUIState: ChapterDetailUIState = ChapterDetailUIState.Loading,
mantraRepository: MantraRepository,
onNavigateBack: () -> Unit,
) {
val chapterDetailViewModel: ChapterDetailViewModel = viewModel(
factory = ChapterDetailViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
chapterId = chapterId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialChapterDetailUIState = initialChapterDetailUIState,
mantraRepository = mantraRepository,
)
)
when (val chapterDetailUIState = chapterDetailViewModel.chapterDetailUIState) {
is ChapterDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = chapterDetailUIState.message)
}
}
is ChapterDetailUIState.Loaded -> {
val chapter = chapterDetailUIState.chapter
Scaffold(
topBar = {
TopAppBar(
title = { Text(chapter.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
Text(
text = "Chapter ${chapter.index} · ${chapter.wordCount} words · ${chapter.characterCount} characters",
style = MaterialTheme.typography.labelMedium
)
}
// Original text (markdown, shown as-is).
item {
Text(
text = "Original text",
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
Text(
modifier = Modifier.padding(16.dp),
text = chapter.originalText,
style = MaterialTheme.typography.bodyMedium
)
}
}
item { HorizontalDivider() }
// Chunks
item {
Text(
text = "Chunks (${chapterDetailUIState.chunks.size})",
style = MaterialTheme.typography.labelMedium
)
}
if (chapterDetailUIState.chunks.isEmpty()) {
item { Text("No chunks.") }
} else {
items(
items = chapterDetailUIState.chunks,
key = { chunk -> chunk.id }
) { chunk ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Segment,
contentDescription = "Chunk"
)
},
overlineContent = { Text("Chunk ${chunk.index}") },
headlineContent = { Text(chunk.text) },
supportingContent = {
Text("${chunk.wordCount} words · ${chunk.characterCount} characters")
}
)
}
}
}
}
}
}
ChapterDetailUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Chapter Detail",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialChapterDetailUIState == ChapterDetailUIState.Loading) {
chapterDetailViewModel.initiateChapterDetail()
}
}
}
@Preview
@Composable
private fun ChapterDetailScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
ChapterDetailScreen(
activeUserPublicKey = "",
chapterId = "chapterId",
chatRoomId = "chatRoomId",
relayHint = null,
initialChapterDetailUIState = ChapterDetailUIState.Loaded(
chapter = MantraChapter(
id = "chapterId",
artifactVersionId = "versionId",
publicKey = "author",
name = "Chapter 1 — The Beginning",
originalText = "When he was nearly thirteen...\n\nWhen enough years had gone by...",
index = 0,
wordCount = 12,
characterCount = 64,
signature = "",
chatRoomId = "chatRoomId"
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateBack = {}
)
}
}
}

View File

@@ -91,8 +91,10 @@ 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.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
@Composable
fun MantraNavHost(
@@ -698,6 +700,20 @@ fun MantraNavHost(
}
)
}
composable<ChapterDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChapterDetailRoute>()
ChapterDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
chapterId = route.chapterId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<AddChapterRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddChapterRoute>()

View File

@@ -0,0 +1,11 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class ChapterDetailRoute(
val activeUserPublicKey: String,
val chapterId: String,
val chatRoomId: String,
val relayHint: String?
): Route()

View File

@@ -0,0 +1,72 @@
package press.mantra.compose.ui.view.model
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.ChapterDetailUIState
class ChapterDetailViewModel(
val chapterId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialChapterDetailUIState: ChapterDetailUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var chapterDetailUIState: ChapterDetailUIState by mutableStateOf(initialChapterDetailUIState)
private set
private val logger = Logger.withTag(TAG)
fun initiateChapterDetail() {
logger.d("initiateChapterDetail: $chapterId")
viewModelScope.launch(Dispatchers.IO) {
val chapter = mantraRepository.getChapter(chapterId)
chapterDetailUIState = if (chapter == null) {
ChapterDetailUIState.Error("Couldn't find the chapter")
} else {
ChapterDetailUIState.Loaded(
chapter = chapter,
chunks = mantraRepository.getChunksForChapter(chapterId),
)
}
}
}
companion object {
private const val TAG = "ChapterDetailViewModel"
fun factory(
activeUserPublicKey: HexKey,
chapterId: String,
chatRoomId: String,
relayHint: String?,
initialChapterDetailUIState: ChapterDetailUIState = ChapterDetailUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ChapterDetailViewModel(
activeUserPublicKey = activeUserPublicKey,
chapterId = chapterId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialChapterDetailUIState = initialChapterDetailUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraChunk
sealed interface ChapterDetailUIState {
data class Loaded(
val chapter: MantraChapter,
val chunks: List<MantraChunk> = emptyList(),
): ChapterDetailUIState
data class Error(
val message: String
): ChapterDetailUIState
data object Loading: ChapterDetailUIState
}