From 99f99fa959482f334a88e1c1799ca566de5c3789 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 01:52:15 +0200 Subject: [PATCH] Add the add-translation workflow from the artifact detail screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- .../model/MantraTranslationArtifactVersion.kt | 32 +- .../model/MantraTranslationChapter.kt | 30 +- .../database/model/MantraTranslationChunk.kt | 30 +- .../repository/DatabaseMantraRepository.kt | 108 +++++++ .../TranslationArtifactVersionEvent.kt | 15 +- .../nostr/nip30303/TranslationChapterEvent.kt | 11 +- .../nostr/nip30303/TranslationChunkEvent.kt | 12 +- .../compose/repository/MantraRepository.kt | 21 ++ .../ui/composable/AddTranslationScreen.kt | 279 ++++++++++++++++++ .../ui/composable/ArtifactDetailScreen.kt | 24 ++ .../ui/composable/navigation/MantraNavHost.kt | 25 ++ .../navigation/routes/AddTranslationRoute.kt | 11 + .../ui/view/model/AddTranslationViewModel.kt | 137 +++++++++ .../ui/view/state/AddTranslationUIState.kt | 17 ++ 14 files changed, 739 insertions(+), 13 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddTranslationRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt index e07aeb4e..530bb0a4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt @@ -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() + // Tag order matches TranslationArtifactVersionEvent.build so the id round-trips. + tags = TagArrayBuilder() + .addUnique(AltTag.assemble(TranslationArtifactVersionEvent.ALT_DESCRIPTION)) .addUnique( ArtifactVersionIdTag.assemble(artifactVersionId) ) @@ -92,6 +96,30 @@ data class MantraTranslationArtifactVersion( } companion object { + fun fromTranslationArtifactVersionEventTemplate( + translationArtifactVersionEventTemplate: EventTemplate, + 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 -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt index eba72fb7..3015af9a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChapter.kt @@ -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() + .addUnique(AltTag.assemble(TranslationChapterEvent.ALT_DESCRIPTION)) .addUnique( TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId) ) @@ -87,6 +91,30 @@ data class MantraTranslationChapter( } companion object { + fun fromTranslationChapterEventTemplate( + translationChapterEventTemplate: EventTemplate, + 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 -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt index 7592891e..4836650c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationChunk.kt @@ -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() + .addUnique(AltTag.assemble(TranslationChunkEvent.ALT_DESCRIPTION)) .addUnique( TranslationChapterIdTag.assemble(translationChapterId) ) @@ -89,6 +93,30 @@ data class MantraTranslationChunk( } companion object { + fun fromTranslationChunkEventTemplate( + translationChunkEventTemplate: EventTemplate, + 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 -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 070bfc60..790ba964 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -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 = 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) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationArtifactVersionEvent.kt index 4637a50d..65828942 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationArtifactVersionEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationArtifactVersionEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> 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() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChapterEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChapterEvent.kt index e3f6a8e4..456242d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChapterEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChapterEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { alt(ALT_DESCRIPTION) + addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)) + addUnique(ChapterIdTag.assemble(chapterId)) + addUnique(IndexTag.assemble(index)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChunkEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChunkEvent.kt index e493f19d..734c1b9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChunkEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/TranslationChunkEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, text, createdAt) { alt(ALT_DESCRIPTION) + addUnique(TranslationChapterIdTag.assemble(translationChapterId)) + addUnique(ChunkIdTag.assemble(chunkId)) + addUnique(IndexTag.assemble(index)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index bcfb0438..74e5c27f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -24,6 +24,20 @@ interface MantraRepository { suspend fun getTranslationsForArtifact(artifactId: String): List + /** + * 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 = emptyList() + override suspend fun addTranslation( + artifactId: String, + dialectId: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraTranslationArtifactVersion? = null + override suspend fun addChapter( artifactId: String, name: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationScreen.kt new file mode 100644 index 00000000..18e2b45f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationScreen.kt @@ -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 = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 5a04cb6c..2c053e51 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -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") + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7512d5d7..03d39aad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + + 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 { + inclusive = true + } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddTranslationRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddTranslationRoute.kt new file mode 100644 index 00000000..53610638 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddTranslationRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationViewModel.kt new file mode 100644 index 00000000..6ae0a974 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddTranslationViewModel.kt @@ -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 = 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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationUIState.kt new file mode 100644 index 00000000..88b2922a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddTranslationUIState.kt @@ -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 = emptyList(), + ): AddTranslationUIState + + data class Error( + val message: String + ): AddTranslationUIState + + data object Loading: AddTranslationUIState +}