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:
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user