From b2664bf7a505ce6f3b7d0cee39e34584599a4356 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 01:34:12 +0200 Subject: [PATCH] Add ChapterDetailScreen showing chapter text and chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compose/database/dao/MantraChapterDao.kt | 3 + .../compose/database/dao/MantraChunkDao.kt | 4 + .../repository/DatabaseMantraRepository.kt | 6 + .../compose/repository/MantraRepository.kt | 9 + .../ui/composable/ArtifactDetailScreen.kt | 14 +- .../ui/composable/ChapterDetailScreen.kt | 211 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 16 ++ .../navigation/routes/ChapterDetailRoute.kt | 11 + .../ui/view/model/ChapterDetailViewModel.kt | 72 ++++++ .../ui/view/state/ChapterDetailUIState.kt | 17 ++ 10 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ChapterDetailRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChapterDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChapterDetailUIState.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 02695c17..d9cb9533 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 @@ -26,4 +26,7 @@ interface MantraChapterDao { @Query("SELECT * FROM MantraChapter WHERE artifactVersionId = :artifactVersionId ORDER BY `index` ASC") suspend fun getChaptersByArtifactVersionId(artifactVersionId: String): List + + @Query("SELECT * FROM MantraChapter WHERE id = :id") + suspend fun getChapterById(id: String): MantraChapter? } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt index e9d789e3..bcab8aee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt @@ -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 } \ No newline at end of file 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 6f87504a..070bfc60 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 @@ -39,6 +39,12 @@ class DatabaseMantraRepository( override suspend fun getChaptersForArtifact(artifactId: String): List = database.mantraChapterDao().getChaptersByArtifactId(artifactId) + override suspend fun getChapter(id: String): MantraChapter? = + database.mantraChapterDao().getChapterById(id) + + override suspend fun getChunksForChapter(chapterId: String): List = + database.mantraChunkDao().getChunksByChapterId(chapterId) + override suspend fun getTranslationsForArtifact(artifactId: String): List = database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId) 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 23d98928..bcfb0438 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -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 + suspend fun getChapter(id: String): MantraChapter? + + suspend fun getChunksForChapter(chapterId: String): List + suspend fun getTranslationsForArtifact(artifactId: String): List /** @@ -74,6 +79,10 @@ interface MantraRepository { override suspend fun getChaptersForArtifact(artifactId: String): List = emptyList() + override suspend fun getChapter(id: String): MantraChapter? = null + + override suspend fun getChunksForChapter(chapterId: String): List = emptyList() + override suspend fun getTranslationsForArtifact(artifactId: String): List = emptyList() override suspend fun addChapter( 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 bf04f888..5a04cb6c 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 @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt new file mode 100644 index 00000000..54cfe1c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -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 = {} + ) + } + } +} 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 d0cb8f5d..7512d5d7 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 @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + + ChapterDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + chapterId = route.chapterId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ChapterDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ChapterDetailRoute.kt new file mode 100644 index 00000000..b5f7d7ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ChapterDetailRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChapterDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChapterDetailViewModel.kt new file mode 100644 index 00000000..4483a81e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChapterDetailViewModel.kt @@ -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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChapterDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChapterDetailUIState.kt new file mode 100644 index 00000000..0e162a39 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChapterDetailUIState.kt @@ -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 = emptyList(), + ): ChapterDetailUIState + + data class Error( + val message: String + ): ChapterDetailUIState + + data object Loading: ChapterDetailUIState +}