Add the add-translation workflow from the artifact detail screen
An Add Translation button in the artifact detail's Translations section opens a workflow that requires selecting or creating a dialect (reusing the chip picker + create-new-dialect fields), then scaffolds the translation. - MantraRepository.addTranslation creates a MantraTranslationArtifactVersion for the artifact's latest version and the chosen dialectId (inheriting the source artifact's name/visibility/license), then mirrors the source structure: a MantraTranslationChapter per chapter and a MantraTranslationChunk (empty text scaffold) per chunk. Each created entity also gets a MarmotInnerEvent rumor (kinds 30306/30308/30309). When a new dialect is requested the ViewModel creates it first via addDialect. - The three translation events' build methods now take real fields (fixing their phantom generics); toXEvent tag order matches build and fromXEventTemplate companions are added so the event ids round-trip. - New AddTranslationScreen (route + ViewModel + UIState). On success it lands on a freshly-loaded artifact detail via popUpTo<ArtifactDetailRoute>. No MantraTranslation rows / translation text are created here — that is the per-chunk authoring step, intentionally left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user