Merge branch 'claude/admiring-mahavira-a862d1' into artifacts

This commit is contained in:
Kgothatso Ngako
2026-07-26 02:06:06 +02:00
13 changed files with 407 additions and 11 deletions

View File

@@ -14,4 +14,7 @@ interface MantraDialectDao {
@Query("SELECT * FROM MantraDialect WHERE chatRoomId = :chatRoomId ORDER BY name")
suspend fun getDialectsByChatRoomId(chatRoomId: String): List<MantraDialect>
@Query("SELECT * FROM MantraDialect WHERE id = :id")
suspend fun getDialectById(id: String): MantraDialect?
}

View File

@@ -23,4 +23,7 @@ interface MantraTranslationArtifactVersionDao {
"""
)
suspend fun getTranslationsByArtifactId(artifactId: String): List<MantraTranslationArtifactVersion>
@Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id")
suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion?
}

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.MantraTranslationChapter
@@ -10,4 +11,7 @@ import press.mantra.compose.database.model.MantraTranslationChapter
interface MantraTranslationChapterDao {
@Upsert
suspend fun upsert(mantraTranslationChapter: MantraTranslationChapter)
@Query("SELECT * FROM MantraTranslationChapter WHERE translationArtifactVersionId = :translationArtifactVersionId ORDER BY `index` ASC")
suspend fun getTranslationChaptersByTranslationArtifactVersionId(translationArtifactVersionId: String): List<MantraTranslationChapter>
}

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.MantraTranslationChunk
@@ -10,4 +11,7 @@ import press.mantra.compose.database.model.MantraTranslationChunk
interface MantraTranslationChunkDao {
@Upsert
suspend fun upsert(mantraTranslationChunk: MantraTranslationChunk)
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
}

View File

@@ -54,12 +54,14 @@ class DatabaseMantraRepository(
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
.firstOrNull() ?: return null
val dialect = database.mantraDialectDao().getDialectById(dialectId) ?: return null
// The translation inherits the source artifact's name/visibility/license.
// The translation is named after its dialect, and inherits the source
// artifact's visibility/license.
val translationVersionTemplate = TranslationArtifactVersionEvent.build(
artifactVersionId = version.id,
dialectId = dialectId,
name = artifact.name,
name = dialect.name,
visibility = artifact.visibility,
license = artifact.license,
)
@@ -156,6 +158,15 @@ class DatabaseMantraRepository(
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? =
database.mantraTranslationArtifactVersionDao().getTranslationById(id)
override suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter> =
database.mantraTranslationChapterDao().getTranslationChaptersByTranslationArtifactVersionId(translationArtifactVersionId)
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> =
database.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(translationChapterId)
override suspend fun addChapter(
artifactId: String,
name: String,

View File

@@ -7,6 +7,8 @@ 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.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
interface MantraRepository {
@@ -24,6 +26,12 @@ interface MantraRepository {
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter>
suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk>
/**
* Start a translation of the artifact into the given dialect: creates a
* MantraTranslationArtifactVersion for the artifact's latest version, and
@@ -99,6 +107,12 @@ interface MantraRepository {
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
override suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter> = emptyList()
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> = emptyList()
override suspend fun addTranslation(
artifactId: String,
dialectId: String,

View File

@@ -38,10 +38,12 @@ 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.DetailRow
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.TranslationDetailRoute
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ArtifactDetailViewModel
@@ -228,7 +230,18 @@ fun ArtifactDetailScreen(
items = artifactDetailUIState.translations,
key = { translation -> translation.id }
) { translation ->
Card {
Card(
onClick = {
onNavigateToRoute.invoke(
TranslationDetailRoute(
activeUserPublicKey = activeUserPublicKey,
translationArtifactVersionId = translation.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
@@ -294,14 +307,6 @@ fun ArtifactDetailScreen(
}
}
@Composable
private fun DetailRow(label: String, value: String) {
ListItem(
overlineContent = { Text(label) },
headlineContent = { Text(value) }
)
}
@Preview
@Composable
private fun ArtifactDetailScreenPreview() {

View File

@@ -0,0 +1,205 @@
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.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.MantraTranslationArtifactVersion
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.widgets.DetailRow
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslationDetailViewModel
import press.mantra.compose.ui.view.state.TranslationDetailUIState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TranslationDetailScreen(
activeUserPublicKey: HexKey,
translationArtifactVersionId: String,
chatRoomId: String,
relayHint: String?,
initialTranslationDetailUIState: TranslationDetailUIState = TranslationDetailUIState.Loading,
mantraRepository: MantraRepository,
onNavigateBack: () -> Unit,
) {
val translationDetailViewModel: TranslationDetailViewModel = viewModel(
factory = TranslationDetailViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
translationArtifactVersionId = translationArtifactVersionId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslationDetailUIState = initialTranslationDetailUIState,
mantraRepository = mantraRepository,
)
)
when (val translationDetailUIState = translationDetailViewModel.translationDetailUIState) {
is TranslationDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = translationDetailUIState.message)
}
}
is TranslationDetailUIState.Loaded -> {
val translation = translationDetailUIState.translation
Scaffold(
topBar = {
TopAppBar(
title = { Text(translation.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 = translation.name)
DetailRow(label = "Dialect", value = translation.dialectId)
DetailRow(label = "Visibility", value = translation.visibility)
DetailRow(label = "License", value = translation.license)
DetailRow(label = "Source version", value = translation.artifactVersionId)
DetailRow(label = "Author", value = translation.publicKey)
DetailRow(label = "Created", value = translation.createdAt.toString())
}
}
item { HorizontalDivider() }
// Chapters with translation progress
item {
Text(
text = "Chapters",
style = MaterialTheme.typography.labelMedium
)
}
if (translationDetailUIState.chapters.isEmpty()) {
item { Text("No chapters.") }
} else {
items(
items = translationDetailUIState.chapters,
key = { progress -> progress.chapter.id }
) { progress ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
)
},
headlineContent = { Text("Chapter ${progress.chapter.index}") },
supportingContent = {
Text("${progress.translatedChunks}/${progress.totalChunks} chunks translated")
}
)
}
}
}
}
}
}
TranslationDetailUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Translation Detail",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialTranslationDetailUIState == TranslationDetailUIState.Loading) {
translationDetailViewModel.initiateTranslationDetail()
}
}
}
@Preview
@Composable
private fun TranslationDetailScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
TranslationDetailScreen(
activeUserPublicKey = "",
translationArtifactVersionId = "translationId",
chatRoomId = "chatRoomId",
relayHint = null,
initialTranslationDetailUIState = TranslationDetailUIState.Loaded(
translation = MantraTranslationArtifactVersion(
id = "translationId",
publicKey = "author",
artifactVersionId = "versionId",
dialectId = "dialectId",
name = "Sesotho",
visibility = "private",
license = "cc",
signature = "",
chatRoomId = "chatRoomId"
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateBack = {}
)
}
}
}

View File

@@ -93,10 +93,12 @@ import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddTranslationScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslationDetailScreen
@Composable
fun MantraNavHost(
@@ -716,6 +718,20 @@ fun MantraNavHost(
}
)
}
composable<TranslationDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<TranslationDetailRoute>()
TranslationDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
translationArtifactVersionId = route.translationArtifactVersionId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<AddTranslationRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddTranslationRoute>()

View File

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

View File

@@ -0,0 +1,14 @@
package press.mantra.compose.ui.composable.widgets
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
/** A labelled value row for detail screens (label above, value below). */
@Composable
fun DetailRow(label: String, value: String) {
ListItem(
overlineContent = { Text(label) },
headlineContent = { Text(value) }
)
}

View File

@@ -0,0 +1,82 @@
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.TranslationChapterProgress
import press.mantra.compose.ui.view.state.TranslationDetailUIState
class TranslationDetailViewModel(
val translationArtifactVersionId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialTranslationDetailUIState: TranslationDetailUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var translationDetailUIState: TranslationDetailUIState by mutableStateOf(initialTranslationDetailUIState)
private set
private val logger = Logger.withTag(TAG)
fun initiateTranslationDetail() {
logger.d("initiateTranslationDetail: $translationArtifactVersionId")
viewModelScope.launch(Dispatchers.IO) {
val translation = mantraRepository.getTranslation(translationArtifactVersionId)
translationDetailUIState = if (translation == null) {
TranslationDetailUIState.Error("Couldn't find the translation")
} else {
val chapters = mantraRepository.getTranslationChapters(translationArtifactVersionId)
.map { chapter ->
val chunks = mantraRepository.getTranslationChunks(chapter.id)
TranslationChapterProgress(
chapter = chapter,
totalChunks = chunks.size,
translatedChunks = chunks.count { it.text.isNotBlank() },
)
}
TranslationDetailUIState.Loaded(
translation = translation,
chapters = chapters,
)
}
}
}
companion object {
private const val TAG = "TranslationDetailViewModel"
fun factory(
activeUserPublicKey: HexKey,
translationArtifactVersionId: String,
chatRoomId: String,
relayHint: String?,
initialTranslationDetailUIState: TranslationDetailUIState = TranslationDetailUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
TranslationDetailViewModel(
activeUserPublicKey = activeUserPublicKey,
translationArtifactVersionId = translationArtifactVersionId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslationDetailUIState = initialTranslationDetailUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,24 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
/** A translation chapter with how many of its chunks have been translated. */
data class TranslationChapterProgress(
val chapter: MantraTranslationChapter,
val totalChunks: Int,
val translatedChunks: Int,
)
sealed interface TranslationDetailUIState {
data class Loaded(
val translation: MantraTranslationArtifactVersion,
val chapters: List<TranslationChapterProgress> = emptyList(),
): TranslationDetailUIState
data class Error(
val message: String
): TranslationDetailUIState
data object Loading: TranslationDetailUIState
}