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

This commit is contained in:
Kgothatso Ngako
2026-07-26 02:19:56 +02:00
10 changed files with 439 additions and 1 deletions

View File

@@ -14,4 +14,7 @@ interface MantraArtifactVersionDao {
@Query("SELECT * FROM MantraArtifactVersion WHERE artifactId = :artifactId ORDER BY createdAt DESC")
suspend fun getArtifactVersionsByArtifactId(artifactId: String): List<MantraArtifactVersion>
@Query("SELECT * FROM MantraArtifactVersion WHERE id = :id")
suspend fun getArtifactVersionById(id: String): MantraArtifactVersion?
}

View File

@@ -14,4 +14,7 @@ interface MantraTranslationChapterDao {
@Query("SELECT * FROM MantraTranslationChapter WHERE translationArtifactVersionId = :translationArtifactVersionId ORDER BY `index` ASC")
suspend fun getTranslationChaptersByTranslationArtifactVersionId(translationArtifactVersionId: String): List<MantraTranslationChapter>
@Query("SELECT * FROM MantraTranslationChapter WHERE id = :id")
suspend fun getTranslationChapterById(id: String): MantraTranslationChapter?
}

View File

@@ -41,6 +41,9 @@ class DatabaseMantraRepository(
override suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion> =
database.mantraArtifactVersionDao().getArtifactVersionsByArtifactId(artifactId)
override suspend fun getArtifactVersion(id: String): MantraArtifactVersion? =
database.mantraArtifactVersionDao().getArtifactVersionById(id)
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
@@ -161,6 +164,9 @@ class DatabaseMantraRepository(
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? =
database.mantraTranslationArtifactVersionDao().getTranslationById(id)
override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? =
database.mantraTranslationChapterDao().getTranslationChapterById(id)
override suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter> =
database.mantraTranslationChapterDao().getTranslationChaptersByTranslationArtifactVersionId(translationArtifactVersionId)
@@ -251,6 +257,9 @@ class DatabaseMantraRepository(
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> =
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
override suspend fun getDialect(id: String): MantraDialect? =
database.mantraDialectDao().getDialectById(id)
override suspend fun addDialect(
name: String,
country: String,

View File

@@ -18,6 +18,8 @@ interface MantraRepository {
suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion>
suspend fun getArtifactVersion(id: String): MantraArtifactVersion?
suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter>
suspend fun getChapter(id: String): MantraChapter?
@@ -28,6 +30,8 @@ interface MantraRepository {
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
suspend fun getTranslationChapter(id: String): MantraTranslationChapter?
suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter>
suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk>
@@ -62,6 +66,8 @@ interface MantraRepository {
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
suspend fun getDialect(id: String): MantraDialect?
suspend fun addDialect(
name: String,
country: String,
@@ -99,6 +105,8 @@ interface MantraRepository {
override suspend fun getArtifactVersions(artifactId: String): List<MantraArtifactVersion> = emptyList()
override suspend fun getArtifactVersion(id: String): MantraArtifactVersion? = null
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> = emptyList()
override suspend fun getChapter(id: String): MantraChapter? = null
@@ -109,6 +117,8 @@ interface MantraRepository {
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
override suspend fun getTranslationChapter(id: String): MantraTranslationChapter? = null
override suspend fun getTranslationChapters(translationArtifactVersionId: String): List<MantraTranslationChapter> = emptyList()
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> = emptyList()
@@ -130,6 +140,8 @@ interface MantraRepository {
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
override suspend fun getDialect(id: String): MantraDialect? = null
override suspend fun addDialect(
name: String,
country: String,

View File

@@ -0,0 +1,238 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.material3.VerticalDivider
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.font.FontWeight
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.MantraChunk
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.TranslationChapterViewModel
import press.mantra.compose.ui.view.state.ChunkTranslationPair
import press.mantra.compose.ui.view.state.TranslationChapterUIState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TranslationChapterScreen(
activeUserPublicKey: HexKey,
translationChapterId: String,
chatRoomId: String,
relayHint: String?,
initialTranslationChapterUIState: TranslationChapterUIState = TranslationChapterUIState.Loading,
mantraRepository: MantraRepository,
onNavigateBack: () -> Unit,
) {
val translationChapterViewModel: TranslationChapterViewModel = viewModel(
factory = TranslationChapterViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslationChapterUIState = initialTranslationChapterUIState,
mantraRepository = mantraRepository,
)
)
when (val translationChapterUIState = translationChapterViewModel.translationChapterUIState) {
is TranslationChapterUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = translationChapterUIState.message)
}
}
is TranslationChapterUIState.Loaded -> {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Chapter translation") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
// Header row: dialect names.
item {
TableRow(
left = {
Text(
text = translationChapterUIState.originalDialectName,
fontWeight = FontWeight.Bold
)
},
right = {
Text(
text = translationChapterUIState.translationDialectName,
fontWeight = FontWeight.Bold
)
},
)
HorizontalDivider()
}
if (translationChapterUIState.rows.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("This chapter has no chunks.")
}
}
} else {
items(
items = translationChapterUIState.rows,
key = { pair -> pair.originalChunk.id }
) { pair ->
ChunkTranslationRow(pair)
HorizontalDivider()
}
}
}
}
}
TranslationChapterUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Chapter Translation",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialTranslationChapterUIState == TranslationChapterUIState.Loading) {
translationChapterViewModel.initiateTranslationChapter()
}
}
}
@Composable
private fun ChunkTranslationRow(pair: ChunkTranslationPair) {
val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() }
TableRow(
left = { Text(pair.originalChunk.text) },
right = {
if (translated != null) {
Text(translated)
} else {
// No translation yet: show the original as a greyed-out placeholder.
Text(
text = pair.originalChunk.text,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
},
)
}
@Composable
private fun TableRow(
left: @Composable () -> Unit,
right: @Composable () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
Box(modifier = Modifier.weight(1f).padding(12.dp)) { left() }
VerticalDivider(modifier = Modifier.fillMaxHeight())
Box(modifier = Modifier.weight(1f).padding(12.dp)) { right() }
}
}
@Preview
@Composable
private fun TranslationChapterScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
TranslationChapterScreen(
activeUserPublicKey = "",
translationChapterId = "translationChapterId",
chatRoomId = "chatRoomId",
relayHint = null,
initialTranslationChapterUIState = TranslationChapterUIState.Loaded(
originalDialectName = "English",
translationDialectName = "Sesotho",
rows = listOf(
ChunkTranslationPair(
originalChunk = sampleChunk("c1", "When he was nearly thirteen, my brother Jem got his arm badly broken."),
translationChunk = null
),
ChunkTranslationPair(
originalChunk = sampleChunk("c2", "When enough years had gone by to enable us to look back on them..."),
translationChunk = null
),
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateBack = {}
)
}
}
}
private fun sampleChunk(id: String, text: String) = MantraChunk(
id = id,
chapterId = "chapterId",
publicKey = "author",
text = text,
index = 0,
wordCount = 0,
characterCount = 0,
signature = "",
chatRoomId = "chatRoomId"
)

View File

@@ -34,6 +34,8 @@ 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.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
import press.mantra.compose.ui.composable.widgets.DetailRow
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -49,6 +51,7 @@ fun TranslationDetailScreen(
relayHint: String?,
initialTranslationDetailUIState: TranslationDetailUIState = TranslationDetailUIState.Loading,
mantraRepository: MantraRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
val translationDetailViewModel: TranslationDetailViewModel = viewModel(
@@ -129,7 +132,18 @@ fun TranslationDetailScreen(
items = translationDetailUIState.chapters,
key = { progress -> progress.chapter.id }
) { progress ->
Card {
Card(
onClick = {
onNavigateToRoute.invoke(
TranslationChapterRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = progress.chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
@@ -198,6 +212,7 @@ private fun TranslationDetailScreenPreview() {
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)
}

View File

@@ -93,11 +93,13 @@ 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.TranslationChapterRoute
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.TranslationChapterScreen
import press.mantra.compose.ui.composable.TranslationDetailScreen
@Composable
@@ -727,6 +729,25 @@ fun MantraNavHost(
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<TranslationChapterRoute> { backStackEntry ->
val route = backStackEntry.toRoute<TranslationChapterRoute>()
TranslationChapterScreen(
activeUserPublicKey = route.activeUserPublicKey,
translationChapterId = route.translationChapterId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateBack = {
navController.popBackStack()
}

View File

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

View File

@@ -0,0 +1,98 @@
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.ChunkTranslationPair
import press.mantra.compose.ui.view.state.TranslationChapterUIState
class TranslationChapterViewModel(
val translationChapterId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialTranslationChapterUIState: TranslationChapterUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var translationChapterUIState: TranslationChapterUIState by mutableStateOf(initialTranslationChapterUIState)
private set
private val logger = Logger.withTag(TAG)
fun initiateTranslationChapter() {
logger.d("initiateTranslationChapter: $translationChapterId")
viewModelScope.launch(Dispatchers.IO) {
val translationChapter = mantraRepository.getTranslationChapter(translationChapterId)
val sourceChapter = translationChapter?.let { mantraRepository.getChapter(it.chapterId) }
translationChapterUIState = if (translationChapter == null || sourceChapter == null) {
TranslationChapterUIState.Error("Couldn't find the translation chapter")
} else {
// Original dialect: source chapter -> version -> artifact -> dialect.
val originalDialectName = mantraRepository.getArtifactVersion(sourceChapter.artifactVersionId)
?.let { mantraRepository.getArtifact(it.artifactId) }
?.let { mantraRepository.getDialect(it.dialectId) }
?.name ?: "Original"
// Translation dialect: translation version -> dialect.
val translationDialectName = mantraRepository.getTranslation(translationChapter.translationArtifactVersionId)
?.let { mantraRepository.getDialect(it.dialectId) }
?.name ?: "Translation"
// Pair each original chunk with its translation chunk (by chunkId).
val translationChunksByChunkId = mantraRepository
.getTranslationChunks(translationChapter.id)
.associateBy { it.chunkId }
val rows = mantraRepository.getChunksForChapter(sourceChapter.id).map { originalChunk ->
ChunkTranslationPair(
originalChunk = originalChunk,
translationChunk = translationChunksByChunkId[originalChunk.id],
)
}
TranslationChapterUIState.Loaded(
originalDialectName = originalDialectName,
translationDialectName = translationDialectName,
rows = rows,
)
}
}
}
companion object {
private const val TAG = "TranslationChapterViewModel"
fun factory(
activeUserPublicKey: HexKey,
translationChapterId: String,
chatRoomId: String,
relayHint: String?,
initialTranslationChapterUIState: TranslationChapterUIState = TranslationChapterUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
TranslationChapterViewModel(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialTranslationChapterUIState = initialTranslationChapterUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraChunk
import press.mantra.compose.database.model.MantraTranslationChunk
/**
* An original chunk paired with its translation chunk. [translationChunk] is
* null (or has blank text) when the chunk hasn't been translated yet, in which
* case the UI shows the original text as a greyed-out placeholder.
*/
data class ChunkTranslationPair(
val originalChunk: MantraChunk,
val translationChunk: MantraTranslationChunk?,
)
sealed interface TranslationChapterUIState {
data class Loaded(
val originalDialectName: String,
val translationDialectName: String,
val rows: List<ChunkTranslationPair> = emptyList(),
): TranslationChapterUIState
data class Error(
val message: String
): TranslationChapterUIState
data object Loading: TranslationChapterUIState
}