Add ArtifactDetailScreen with chapters and translations

Clicking an artifact in a chat room's library now opens an artifact
detail screen instead of the placeholder route.

- New ArtifactDetailRoute + ArtifactDetailScreen/ViewModel/UIState. The
  screen shows the artifact's details (name, url, visibility, license,
  dialect, author, created), its versions, and — walked via the artifact's
  versions — the associated chapters and translations, each with an empty
  state.
- DAO queries (validated by Room): getArtifactById, versions by artifactId,
  chapters by artifactId (Chapter JOIN ArtifactVersion, ordered by index),
  and translations by artifactId (TranslationArtifactVersion JOIN
  ArtifactVersion). Exposed via MantraRepository (impl + NO_OP).
- ChatRoomDetailScreen navigates to ArtifactDetailRoute on artifact click;
  MantraNavHost registers the route with a back-stack pop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-07-26 01:10:22 +02:00
parent 308a28c073
commit d671c7c300
12 changed files with 462 additions and 2 deletions

View File

@@ -12,4 +12,7 @@ interface MantraArtifactDao {
@Query("SELECT * FROM MantraArtifact WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
suspend fun getArtifactsByChatRoomId(chatRoomId: String): List<MantraArtifact>
@Query("SELECT * FROM MantraArtifact WHERE id = :id")
suspend fun getArtifactById(id: String): MantraArtifact?
}

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.MantraArtifactVersion
@@ -10,4 +11,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion
interface MantraArtifactVersionDao {
@Upsert
suspend fun upsert(mantraArtifactVersion: MantraArtifactVersion)
@Query("SELECT * FROM MantraArtifactVersion WHERE artifactId = :artifactId ORDER BY createdAt DESC")
suspend fun getArtifactVersionsByArtifactId(artifactId: String): List<MantraArtifactVersion>
}

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.MantraChapter
@@ -10,4 +11,16 @@ import press.mantra.compose.database.model.MantraChapter
interface MantraChapterDao {
@Upsert
suspend fun upsert(mantraChapter: MantraChapter)
/** Chapters across all versions of an artifact, ordered by their in-book index. */
@Query(
"""
SELECT MantraChapter.* FROM MantraChapter
INNER JOIN MantraArtifactVersion
ON MantraChapter.artifactVersionId = MantraArtifactVersion.id
WHERE MantraArtifactVersion.artifactId = :artifactId
ORDER BY MantraChapter.`index` ASC
"""
)
suspend fun getChaptersByArtifactId(artifactId: String): List<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.MantraTranslationArtifactVersion
@@ -10,4 +11,16 @@ import press.mantra.compose.database.model.MantraTranslationArtifactVersion
interface MantraTranslationArtifactVersionDao {
@Upsert
suspend fun upsert(mantraTranslationArtifactVersion: MantraTranslationArtifactVersion)
/** Translations targeting any version of an artifact. */
@Query(
"""
SELECT MantraTranslationArtifactVersion.* FROM MantraTranslationArtifactVersion
INNER JOIN MantraArtifactVersion
ON MantraTranslationArtifactVersion.artifactVersionId = MantraArtifactVersion.id
WHERE MantraArtifactVersion.artifactId = :artifactId
ORDER BY MantraTranslationArtifactVersion.createdAt DESC
"""
)
suspend fun getTranslationsByArtifactId(artifactId: String): List<MantraTranslationArtifactVersion>
}

View File

@@ -6,7 +6,9 @@ import kotlinx.coroutines.CoroutineScope
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.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
@@ -24,6 +26,18 @@ class DatabaseMantraRepository(
override suspend fun getArtifacts(chatRoomId: String): List<MantraArtifact> =
database.mantraArtifactDao().getArtifactsByChatRoomId(chatRoomId)
override suspend fun getArtifact(id: String): MantraArtifact? =
database.mantraArtifactDao().getArtifactById(id)
override suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion> =
database.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(artifactId)
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> =
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)

View File

@@ -2,12 +2,23 @@ package press.mantra.compose.repository
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.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MarmotInnerEvent
interface MantraRepository {
suspend fun getArtifacts(chatRoomId: String): List<MantraArtifact>
suspend fun getArtifact(id: String): MantraArtifact?
suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion>
suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter>
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
suspend fun addDialect(
@@ -43,6 +54,14 @@ interface MantraRepository {
val NO_OP_MANTRA_REPOSITORY = object: MantraRepository{
override suspend fun getArtifacts(chatRoomId: String): List<MantraArtifact> = emptyList()
override suspend fun getArtifact(id: String): MantraArtifact? = null
override suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion> = emptyList()
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> = emptyList()
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
override suspend fun addDialect(

View File

@@ -0,0 +1,268 @@
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.Article
import androidx.compose.material.icons.filled.Translate
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.MantraArtifact
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.ArtifactDetailViewModel
import press.mantra.compose.ui.view.state.ArtifactDetailUIState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtifactDetailScreen(
activeUserPublicKey: HexKey,
artifactId: String,
chatRoomId: String,
relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository,
onNavigateBack: () -> Unit,
) {
val artifactDetailViewModel: ArtifactDetailViewModel = viewModel(
factory = ArtifactDetailViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository,
)
)
when (val artifactDetailUIState = artifactDetailViewModel.artifactDetailUIState) {
is ArtifactDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = artifactDetailUIState.message)
}
}
is ArtifactDetailUIState.Loaded -> {
val artifact = artifactDetailUIState.artifact
Scaffold(
topBar = {
TopAppBar(
title = { Text(artifact.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)
) {
// Details
item {
Text(
text = "Details",
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
DetailRow(label = "Name", value = artifact.name)
DetailRow(label = "Url", value = artifact.url)
DetailRow(label = "Visibility", value = artifact.visibility)
DetailRow(label = "License", value = artifact.license)
DetailRow(label = "Dialect", value = artifact.dialectId)
DetailRow(label = "Author", value = artifact.publicKey)
DetailRow(label = "Created", value = artifact.createdAt.toString())
}
}
// Versions
item {
Text(
text = "Versions",
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.versions.isEmpty()) {
item { Text("No versions.") }
} else {
items(
items = artifactDetailUIState.versions,
key = { version -> version.id }
) { version ->
Card {
ListItem(
headlineContent = { Text(version.versionLabel) },
overlineContent = { Text("Version") }
)
}
}
}
item { HorizontalDivider() }
// Chapters
item {
Text(
text = "Chapters",
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.chapters.isEmpty()) {
item { Text("No chapters yet.") }
} else {
items(
items = artifactDetailUIState.chapters,
key = { chapter -> chapter.id }
) { chapter ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
)
},
overlineContent = { Text("Chapter ${chapter.index}") },
headlineContent = { Text(chapter.name) },
supportingContent = {
Text("${chapter.wordCount} words · ${chapter.characterCount} characters")
}
)
}
}
}
item { HorizontalDivider() }
// Translations
item {
Text(
text = "Translations",
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.translations.isEmpty()) {
item { Text("No translations yet.") }
} else {
items(
items = artifactDetailUIState.translations,
key = { translation -> translation.id }
) { translation ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Translation"
)
},
headlineContent = { Text(translation.name) },
supportingContent = { Text(translation.visibility) }
)
}
}
}
}
}
}
ArtifactDetailUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Artifact Detail",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialArtifactDetailUIState == ArtifactDetailUIState.Loading) {
artifactDetailViewModel.initiateArtifactDetail()
}
}
}
@Composable
private fun DetailRow(label: String, value: String) {
ListItem(
overlineContent = { Text(label) },
headlineContent = { Text(value) }
)
}
@Preview
@Composable
private fun ArtifactDetailScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
ArtifactDetailScreen(
activeUserPublicKey = "",
artifactId = "artifactId",
chatRoomId = "chatRoomId",
relayHint = null,
initialArtifactDetailUIState = ArtifactDetailUIState.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,
onNavigateBack = {}
)
}
}
}

View File

@@ -58,6 +58,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -150,8 +151,11 @@ fun ChatRoomDetailScreen(
Card(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Artifact detail"
ArtifactDetailRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}

View File

@@ -89,6 +89,8 @@ 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.ArtifactDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
@Composable
fun MantraNavHost(
@@ -675,6 +677,20 @@ fun MantraNavHost(
}
)
}
composable<ArtifactDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ArtifactDetailRoute>()
ArtifactDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
artifactId = route.artifactId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<SearchRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SearchRoute>()

View File

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

View File

@@ -0,0 +1,74 @@
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.ArtifactDetailUIState
class ArtifactDetailViewModel(
val artifactId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var artifactDetailUIState: ArtifactDetailUIState by mutableStateOf(initialArtifactDetailUIState)
private set
private val logger = Logger.withTag(TAG)
fun initiateArtifactDetail() {
logger.d("initiateArtifactDetail: $artifactId")
viewModelScope.launch(Dispatchers.IO) {
val artifact = mantraRepository.getArtifact(artifactId)
artifactDetailUIState = if (artifact == null) {
ArtifactDetailUIState.Error("Couldn't find the artifact")
} else {
ArtifactDetailUIState.Loaded(
artifact = artifact,
versions = mantraRepository.getArtifactVersions(artifactId),
chapters = mantraRepository.getChaptersForArtifact(artifactId),
translations = mantraRepository.getTranslationsForArtifact(artifactId),
)
}
}
}
companion object {
private const val TAG = "ArtifactDetailViewModel"
fun factory(
activeUserPublicKey: HexKey,
artifactId: String,
chatRoomId: String,
relayHint: String?,
initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ArtifactDetailViewModel(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialArtifactDetailUIState = initialArtifactDetailUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,21 @@
package press.mantra.compose.ui.view.state
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.MantraTranslationArtifactVersion
sealed interface ArtifactDetailUIState {
data class Loaded(
val artifact: MantraArtifact,
val versions: List<MantraArtifactVersion> = emptyList(),
val chapters: List<MantraChapter> = emptyList(),
val translations: List<MantraTranslationArtifactVersion> = emptyList(),
): ArtifactDetailUIState
data class Error(
val message: String
): ArtifactDetailUIState
data object Loading: ArtifactDetailUIState
}