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

This commit is contained in:
Kgothatso Ngako
2026-07-26 01:53:02 +02:00
14 changed files with 739 additions and 13 deletions

View File

@@ -5,9 +5,11 @@ import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag
import press.mantra.compose.nostr.nip30303.tags.DialectIdTag
@@ -75,7 +77,9 @@ data class MantraTranslationArtifactVersion(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
tags = TagArrayBuilder<ArtifactVersionEvent>()
// Tag order matches TranslationArtifactVersionEvent.build so the id round-trips.
tags = TagArrayBuilder<TranslationArtifactVersionEvent>()
.addUnique(AltTag.assemble(TranslationArtifactVersionEvent.ALT_DESCRIPTION))
.addUnique(
ArtifactVersionIdTag.assemble(artifactVersionId)
)
@@ -92,6 +96,30 @@ data class MantraTranslationArtifactVersion(
}
companion object {
fun fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate: EventTemplate<TranslationArtifactVersionEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? {
return fromTranslationArtifactVersionEvent(
TranslationArtifactVersionEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = translationArtifactVersionEventTemplate.tags,
content = translationArtifactVersionEventTemplate.content,
createdAt = translationArtifactVersionEventTemplate.createdAt,
kind = translationArtifactVersionEventTemplate.kind,
),
content = translationArtifactVersionEventTemplate.content,
tags = translationArtifactVersionEventTemplate.tags,
createdAt = translationArtifactVersionEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromTranslationArtifactVersionEvent(translationArtifactVersionEvent: TranslationArtifactVersionEvent, chatRoomId: HexKey): MantraTranslationArtifactVersion? {
return translationArtifactVersionEvent.artifactVersionId()?.let { artifactVersionId ->

View File

@@ -5,9 +5,11 @@ import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
import press.mantra.compose.nostr.nip30303.tags.IndexTag
@@ -70,7 +72,9 @@ data class MantraTranslationChapter(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
// Tag order matches TranslationChapterEvent.build so the id round-trips.
tags = TagArrayBuilder<TranslationChapterEvent>()
.addUnique(AltTag.assemble(TranslationChapterEvent.ALT_DESCRIPTION))
.addUnique(
TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)
)
@@ -87,6 +91,30 @@ data class MantraTranslationChapter(
}
companion object {
fun fromTranslationChapterEventTemplate(
translationChapterEventTemplate: EventTemplate<TranslationChapterEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraTranslationChapter? {
return fromTranslationChapterEvent(
TranslationChapterEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = translationChapterEventTemplate.tags,
content = translationChapterEventTemplate.content,
createdAt = translationChapterEventTemplate.createdAt,
kind = translationChapterEventTemplate.kind,
),
content = translationChapterEventTemplate.content,
tags = translationChapterEventTemplate.tags,
createdAt = translationChapterEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromTranslationChapterEvent(translationChapterEvent: TranslationChapterEvent, chatRoomId: HexKey): MantraTranslationChapter? {
return translationChapterEvent.chapterId()?.let { chapterId ->
translationChapterEvent.translationArtifactVersionId()?.let { translationArtifactVersionId ->

View File

@@ -5,9 +5,11 @@ import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ChunkIdTag
import press.mantra.compose.nostr.nip30303.tags.IndexTag
@@ -72,7 +74,9 @@ data class MantraTranslationChunk(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
// Tag order matches TranslationChunkEvent.build so the id round-trips.
tags = TagArrayBuilder<TranslationChunkEvent>()
.addUnique(AltTag.assemble(TranslationChunkEvent.ALT_DESCRIPTION))
.addUnique(
TranslationChapterIdTag.assemble(translationChapterId)
)
@@ -89,6 +93,30 @@ data class MantraTranslationChunk(
}
companion object {
fun fromTranslationChunkEventTemplate(
translationChunkEventTemplate: EventTemplate<TranslationChunkEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraTranslationChunk? {
return fromTranslationChunkEvent(
TranslationChunkEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = translationChunkEventTemplate.tags,
content = translationChunkEventTemplate.content,
createdAt = translationChunkEventTemplate.createdAt,
kind = translationChunkEventTemplate.kind,
),
content = translationChunkEventTemplate.content,
tags = translationChunkEventTemplate.tags,
createdAt = translationChunkEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromTranslationChunkEvent(translationChunkEvent: TranslationChunkEvent, chatRoomId: HexKey): MantraTranslationChunk? {
return translationChunkEvent.translationChapterId()?.let { translationChapterId ->
translationChunkEvent.chunkId()?.let { chunkId ->

View File

@@ -10,12 +10,17 @@ 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
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.text.Markdown
@@ -39,6 +44,109 @@ class DatabaseMantraRepository(
override suspend fun getChaptersForArtifact(artifactId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactId(artifactId)
override suspend fun addTranslation(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? {
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
.firstOrNull() ?: return null
// The translation inherits the source artifact's name/visibility/license.
val translationVersionTemplate = TranslationArtifactVersionEvent.build(
artifactVersionId = version.id,
dialectId = dialectId,
name = artifact.name,
visibility = artifact.visibility,
license = artifact.license,
)
val translationVersion = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = translationVersionTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
return try {
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = translationVersion.id,
publicKey = translationVersion.publicKey,
kind = TranslationArtifactVersionEvent.KIND,
createdAt = translationVersion.createdAt,
tags = translationVersionTemplate.tags,
content = translationVersionTemplate.content,
chatRoomId = translationVersion.chatRoomId,
)
)
// Mirror the source structure: a translation chapter per chapter and
// an (untranslated) translation chunk per chunk.
val chapters = database.mantraChapterDao().getChaptersByArtifactVersionId(version.id)
chapters.forEach { chapter ->
val translationChapterTemplate = TranslationChapterEvent.build(
translationArtifactVersionId = translationVersion.id,
chapterId = chapter.id,
index = chapter.index,
)
val translationChapter = MantraTranslationChapter.fromTranslationChapterEventTemplate(
translationChapterEventTemplate = translationChapterTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return@forEach
database.mantraTranslationChapterDao().upsert(translationChapter)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = translationChapter.id,
publicKey = translationChapter.publicKey,
kind = TranslationChapterEvent.KIND,
createdAt = translationChapter.createdAt,
tags = translationChapterTemplate.tags,
content = translationChapterTemplate.content,
chatRoomId = translationChapter.chatRoomId,
)
)
val chunks = database.mantraChunkDao().getChunksByChapterId(chapter.id)
chunks.forEach { chunk ->
val translationChunkTemplate = TranslationChunkEvent.build(
translationChapterId = translationChapter.id,
chunkId = chunk.id,
index = chunk.index,
text = "", // Untranslated scaffold; filled in during authoring.
)
val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
translationChunkEventTemplate = translationChunkTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return@forEach
database.mantraTranslationChunkDao().upsert(translationChunk)
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = translationChunk.id,
publicKey = translationChunk.publicKey,
kind = TranslationChunkEvent.KIND,
createdAt = translationChunk.createdAt,
tags = translationChunkTemplate.tags,
content = translationChunkTemplate.content,
chatRoomId = translationChunk.chatRoomId,
)
)
}
}
translationVersion
} catch (error: Throwable) {
logger.e("Failed to add translation for artifact $artifactId", error)
null
}
}
override suspend fun getChapter(id: String): MantraChapter? =
database.mantraChapterDao().getChapterById(id)

View File

@@ -39,11 +39,20 @@ class TranslationArtifactVersionEvent(
const val ALT_DESCRIPTION = "TranslationArtifactVersion"
fun build(
content: String,
artifactVersionId: String,
dialectId: String,
name: String,
visibility: String,
license: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
initializer: TagArrayBuilder<TranslationArtifactVersionEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
addUnique(ArtifactVersionIdTag.assemble(artifactVersionId))
addUnique(DialectIdTag.assemble(dialectId))
addUnique(NameTag.assemble(name))
addUnique(VisibilityTag.assemble(visibility))
addUnique(LicenseTag.assemble(license))
initializer()
}
}

View File

@@ -37,11 +37,16 @@ class TranslationChapterEvent(
const val ALT_DESCRIPTION = "TranslationChapter"
fun build(
content: String,
translationArtifactVersionId: String,
chapterId: String,
index: Int,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
initializer: TagArrayBuilder<TranslationChapterEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId))
addUnique(ChapterIdTag.assemble(chapterId))
addUnique(IndexTag.assemble(index))
initializer()
}
}

View File

@@ -38,11 +38,17 @@ class TranslationChunkEvent(
const val ALT_DESCRIPTION = "TranslationChunk"
fun build(
content: String,
translationChapterId: String,
chunkId: String,
index: Int,
text: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
initializer: TagArrayBuilder<TranslationChunkEvent>.() -> Unit = {},
) = eventTemplate(KIND, text, createdAt) {
alt(ALT_DESCRIPTION)
addUnique(TranslationChapterIdTag.assemble(translationChapterId))
addUnique(ChunkIdTag.assemble(chunkId))
addUnique(IndexTag.assemble(index))
initializer()
}
}

View File

@@ -24,6 +24,20 @@ interface MantraRepository {
suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion>
/**
* Start a translation of the artifact into the given dialect: creates a
* MantraTranslationArtifactVersion for the artifact's latest version, and
* scaffolds a MantraTranslationChapter for each existing chapter plus a
* MantraTranslationChunk (empty text) for each existing chunk. Returns null
* when the artifact has no version to translate.
*/
suspend fun addTranslation(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion?
/**
* Add a chapter (markdown [originalText]) to the artifact's latest version.
* Word/character counts are derived from the text, and the chapter is split
@@ -85,6 +99,13 @@ interface MantraRepository {
override suspend fun getTranslationsForArtifact(artifactId: String): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun addTranslation(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? = null
override suspend fun addChapter(
artifactId: String,
name: String,

View File

@@ -0,0 +1,279 @@
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddTranslationViewModel
import press.mantra.compose.ui.view.state.AddTranslationUIState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AddTranslationScreen(
activeUserPublicKey: HexKey,
artifactId: String,
chatRoomId: String,
relayHint: String?,
initialAddTranslationUIState: AddTranslationUIState = AddTranslationUIState.Loading,
mantraRepository: MantraRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
val addTranslationViewModel: AddTranslationViewModel = viewModel(
factory = AddTranslationViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddTranslationUIState = initialAddTranslationUIState,
mantraRepository = mantraRepository,
)
)
when (val addTranslationUIState = addTranslationViewModel.addTranslationUIState) {
is AddTranslationUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = addTranslationUIState.message)
}
}
is AddTranslationUIState.Loaded -> {
val dialectNameFieldState = rememberTextFieldState()
val dialectCountryFieldState = rememberTextFieldState()
val dialectLanguageFieldState = rememberTextFieldState()
// null = "New dialect" (show the create fields); otherwise reuse an
// existing dialect by id.
var selectedDialectId: String? by remember { mutableStateOf(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Translate ${addTranslationUIState.artifact.name}") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
addTranslationViewModel.addTranslation(
existingDialectId = selectedDialectId,
dialectNameField = dialectNameFieldState,
dialectCountryField = dialectCountryFieldState,
dialectLanguageField = dialectLanguageFieldState,
onSuccess = {
onNavigateToRoute.invoke(
ArtifactDetailRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
},
onFailure = {}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Add translation"
)
Text("Add Translation")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Translate into which dialect?")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addTranslationUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
FilterChip(
selected = selectedDialectId == null,
onClick = { selectedDialectId = null },
leadingIcon = {
Icon(
Icons.Default.Add,
contentDescription = "Create a new dialect"
)
},
label = { Text("New dialect") }
)
}
// Only collect new-dialect details when not reusing an existing one.
if (selectedDialectId == null) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectNameFieldState,
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = { Text("Dialect Name") },
placeholder = { Text("eg. Sesotho") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectCountryFieldState,
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = { Text("Country") },
placeholder = { Text("eg. Lesotho") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectLanguageFieldState,
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = { Text("Language") },
placeholder = { Text("eg. st") },
)
}
Text(
text = "The artifact's chapters and chunks will be scaffolded for this translation.",
style = MaterialTheme.typography.labelMedium
)
}
}
}
AddTranslationUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Add Translation",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
LaunchedEffect(true) {
if (initialAddTranslationUIState == AddTranslationUIState.Loading) {
addTranslationViewModel.initiateAddTranslation()
}
}
}
@Preview
@Composable
private fun AddTranslationScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AddTranslationScreen(
activeUserPublicKey = "",
artifactId = "artifactId",
chatRoomId = "chatRoomId",
relayHint = null,
initialAddTranslationUIState = AddTranslationUIState.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,
onNavigateToRoute = {},
onNavigateBack = {}
)
}
}
}

View File

@@ -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.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.widgets.LoadingDataIndicator
@@ -241,6 +242,29 @@ fun ArtifactDetailScreen(
}
}
}
item {
TextButton(
enabled = artifactDetailUIState.versions.isNotEmpty(),
onClick = {
onNavigateToRoute.invoke(
AddTranslationRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add translation"
)
Spacer(modifier = Modifier.width(10.dp))
Text("Add Translation")
}
}
}
}
}

View File

@@ -90,10 +90,12 @@ 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.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.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddTranslationScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
@Composable
@@ -714,6 +716,29 @@ fun MantraNavHost(
}
)
}
composable<AddTranslationRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddTranslationRoute>()
AddTranslationScreen(
activeUserPublicKey = route.activeUserPublicKey,
artifactId = route.artifactId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
onNavigateToRoute = { actionRoute ->
// Replace this screen and the stale artifact detail beneath it
// so we land on a freshly-loaded detail showing the new translation.
navController.navigate(route = actionRoute) {
popUpTo<ArtifactDetailRoute> {
inclusive = true
}
}
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<AddChapterRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddChapterRoute>()

View File

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

View File

@@ -0,0 +1,137 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.MutableState
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.AddTranslationUIState
class AddTranslationViewModel(
val artifactId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialAddTranslationUIState: AddTranslationUIState,
val mantraRepository: MantraRepository,
): ViewModel() {
var addTranslationUIState: AddTranslationUIState by mutableStateOf(initialAddTranslationUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun initiateAddTranslation() {
viewModelScope.launch(Dispatchers.IO) {
val artifact = mantraRepository.getArtifact(artifactId)
addTranslationUIState = if (artifact == null) {
AddTranslationUIState.Error("Couldn't find the artifact")
} else {
AddTranslationUIState.Loaded(
artifact = artifact,
dialects = mantraRepository.getDialects(chatRoomId),
)
}
}
}
fun addTranslation(
existingDialectId: HexKey?,
dialectNameField: TextFieldState,
dialectCountryField: TextFieldState,
dialectLanguageField: TextFieldState,
onSuccess: () -> Unit,
onFailure: () -> Unit
) {
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (creatingNewDialect && newDialectIncomplete) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val translation = runCatching {
// Reuse the selected dialect, or create a new one for the translation.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
chatRoomId = chatRoomId,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId!!
}
mantraRepository.addTranslation(
artifactId = artifactId,
dialectId = dialectId,
chatRoomId = chatRoomId,
userPublicKey = activeUserPublicKey,
)
}.onFailure { error ->
logger.e("Failed to add translation", error)
}.getOrNull()
if (translation != null) {
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
onSuccess.invoke()
} else {
onFailure.invoke()
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "AddTranslationViewModel"
fun factory(
activeUserPublicKey: HexKey,
artifactId: String,
chatRoomId: String,
relayHint: String?,
initialAddTranslationUIState: AddTranslationUIState = AddTranslationUIState.Loading,
mantraRepository: MantraRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddTranslationViewModel(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddTranslationUIState = initialAddTranslationUIState,
mantraRepository = mantraRepository,
)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
sealed interface AddTranslationUIState {
data class Loaded(
val artifact: MantraArtifact,
val dialects: List<MantraDialect> = emptyList(),
): AddTranslationUIState
data class Error(
val message: String
): AddTranslationUIState
data object Loading: AddTranslationUIState
}