From c494cf9e11c2347a5fc7a5c5e99329465a49da9c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 02:29:58 +0200 Subject: [PATCH] Add per-chunk translation editor The translation column of the chapter table is now a TextButton (with a ChevronRight) per chunk; tapping it opens an editor for that chunk. - TranslateChunkScreen (route + screen/ViewModel/UIState) shows the full original chunk and a text input prefilled with any existing translation. Saving persists the translation and returns to a freshly-loaded chapter table (popUpTo) so the update shows. - MantraRepository.saveTranslationChunk builds a MantraTranslationChunk from the entered text (content-hash id, index mirrored from the source chunk), upserts it plus its MarmotInnerEvent rumor, and replaces any prior translation chunk for the same source chunk (deleting the stale row and its rumor) so there is exactly one per source chunk. New DAO queries getChunkById, MantraTranslationChunkDao.deleteById, MarmotInnerEventDao.deleteById, and repository getChunk. - TranslationChapterScreen renders the translation cell as the button and navigates to TranslateChunkRoute with the source chunkId. Co-Authored-By: Claude Opus 4.8 --- .../compose/database/dao/MantraChunkDao.kt | 3 + .../database/dao/MantraTranslationChunkDao.kt | 3 + .../database/dao/MarmotInnerEventDao.kt | 3 + .../repository/DatabaseMantraRepository.kt | 57 +++++ .../compose/repository/MantraRepository.kt | 26 ++ .../ui/composable/TranslateChunkScreen.kt | 223 ++++++++++++++++++ .../ui/composable/TranslationChapterScreen.kt | 53 ++++- .../ui/composable/navigation/MantraNavHost.kt | 31 +++ .../navigation/routes/TranslateChunkRoute.kt | 12 + .../ui/view/model/TranslateChunkViewModel.kt | 116 +++++++++ .../ui/view/state/TranslateChunkUIState.kt | 16 ++ 11 files changed, 535 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/TranslateChunkRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt index bcab8aee..c16c4130 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraChunkDao.kt @@ -14,4 +14,7 @@ interface MantraChunkDao { @Query("SELECT * FROM MantraChunk WHERE chapterId = :chapterId ORDER BY `index` ASC") suspend fun getChunksByChapterId(chapterId: String): List + + @Query("SELECT * FROM MantraChunk WHERE id = :id") + suspend fun getChunkById(id: String): MantraChunk? } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt index 0afc076a..52c1c7a3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationChunkDao.kt @@ -14,4 +14,7 @@ interface MantraTranslationChunkDao { @Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC") suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List + + @Query("DELETE FROM MantraTranslationChunk WHERE id = :id") + suspend fun deleteById(id: String) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt index 8937b2e5..a5fb8866 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotInnerEventDao.kt @@ -14,4 +14,7 @@ interface MarmotInnerEventDao { @Upsert suspend fun upsert(marmotInnerEvent: MarmotInnerEvent) + + @Query("DELETE FROM MarmotInnerEvent WHERE id = :id") + suspend fun deleteById(id: String) } \ No newline at end of file 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 553ecea9..f9c88d1d 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 @@ -158,6 +158,63 @@ class DatabaseMantraRepository( override suspend fun getChunksForChapter(chapterId: String): List = database.mantraChunkDao().getChunksByChapterId(chapterId) + override suspend fun getChunk(id: String): MantraChunk? = + database.mantraChunkDao().getChunkById(id) + + override suspend fun saveTranslationChunk( + translationChapterId: String, + chunkId: String, + text: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraTranslationChunk? { + // The translation chunk mirrors the source chunk's position. + val sourceChunk = database.mantraChunkDao().getChunkById(chunkId) ?: return null + + val translationChunkTemplate = TranslationChunkEvent.build( + translationChapterId = translationChapterId, + chunkId = chunkId, + index = sourceChunk.index, + text = text, + ) + val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate( + translationChunkEventTemplate = translationChunkTemplate, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + ) ?: return null + + return try { + // Replace any existing translation chunk for this source chunk. Its id + // is derived from the (now changed) content, so it becomes a new row — + // drop the old one (and its rumor) to keep one per source chunk. + database.mantraTranslationChunkDao() + .getTranslationChunksByTranslationChapterId(translationChapterId) + .filter { it.chunkId == chunkId && it.id != translationChunk.id } + .forEach { stale -> + database.mantraTranslationChunkDao().deleteById(stale.id) + database.marmotInnerEventDao().deleteById(stale.id) + } + + 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, + ) + ) + + translationChunk + } catch (error: Throwable) { + logger.e("Failed to save translation for chunk $chunkId", error) + null + } + } + override suspend fun getTranslationsForArtifact(artifactId: String): List = database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId) 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 8c129622..f93655ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -26,6 +26,22 @@ interface MantraRepository { suspend fun getChunksForChapter(chapterId: String): List + suspend fun getChunk(id: String): MantraChunk? + + /** + * Create or replace the translation of a source chunk within a translation + * chapter. Any existing translation chunk for the same (translationChapterId, + * chunkId) is replaced. Returns the saved chunk, or null if the source chunk + * can't be found. + */ + suspend fun saveTranslationChunk( + translationChapterId: String, + chunkId: String, + text: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraTranslationChunk? + suspend fun getTranslationsForArtifact(artifactId: String): List suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? @@ -113,6 +129,16 @@ interface MantraRepository { override suspend fun getChunksForChapter(chapterId: String): List = emptyList() + override suspend fun getChunk(id: String): MantraChunk? = null + + override suspend fun saveTranslationChunk( + translationChapterId: String, + chunkId: String, + text: String, + chatRoomId: String, + userPublicKey: HexKey, + ): MantraTranslationChunk? = null + override suspend fun getTranslationsForArtifact(artifactId: String): List = emptyList() override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt new file mode 100644 index 00000000..889ab152 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -0,0 +1,223 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +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.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.MantraChunk +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.TranslateChunkViewModel +import press.mantra.compose.ui.view.state.TranslateChunkUIState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TranslateChunkScreen( + activeUserPublicKey: HexKey, + translationChapterId: String, + chunkId: String, + chatRoomId: String, + relayHint: String?, + initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading, + mantraRepository: MantraRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val translateChunkViewModel: TranslateChunkViewModel = viewModel( + factory = TranslateChunkViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chunkId = chunkId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialTranslateChunkUIState = initialTranslateChunkUIState, + mantraRepository = mantraRepository, + ) + ) + + when (val translateChunkUIState = translateChunkViewModel.translateChunkUIState) { + is TranslateChunkUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text(text = translateChunkUIState.message) + } + } + + is TranslateChunkUIState.Loaded -> { + val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Translate chunk") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + translateChunkViewModel.saveTranslation( + translationField = translationFieldState, + onSuccess = { + // Return to a freshly-loaded chapter table so the + // saved translation is reflected. + onNavigateToRoute.invoke( + TranslationChapterRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + }, + onFailure = {} + ) + } + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save translation" + ) + Text("Save") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Original", + style = MaterialTheme.typography.labelMedium + ) + Card { + Text( + modifier = Modifier.padding(16.dp), + text = translateChunkUIState.originalChunk.text, + style = MaterialTheme.typography.bodyMedium + ) + } + + Text( + text = "Translation", + style = MaterialTheme.typography.labelMedium + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = translationFieldState, + label = { Text("Translated text") }, + placeholder = { Text("Enter the translation for this chunk") }, + ) + } + } + } + + TranslateChunkUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Translate Chunk", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + + LaunchedEffect(true) { + if (initialTranslateChunkUIState == TranslateChunkUIState.Loading) { + translateChunkViewModel.initiateTranslateChunk() + } + } +} + +@Preview +@Composable +private fun TranslateChunkScreenPreview() { + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + TranslateChunkScreen( + activeUserPublicKey = "", + translationChapterId = "translationChapterId", + chunkId = "chunkId", + chatRoomId = "chatRoomId", + relayHint = null, + initialTranslateChunkUIState = TranslateChunkUIState.Loaded( + originalChunk = MantraChunk( + id = "chunkId", + chapterId = "chapterId", + publicKey = "author", + text = "When he was nearly thirteen, my brother Jem got his arm badly broken at the elbow.", + index = 0, + wordCount = 0, + characterCount = 0, + signature = "", + chatRoomId = "chatRoomId" + ), + existingTranslationText = "" + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index c988b079..2a829f6b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -23,6 +25,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -37,6 +40,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.TranslationChapterViewModel @@ -52,6 +57,7 @@ fun TranslationChapterScreen( relayHint: String?, initialTranslationChapterUIState: TranslationChapterUIState = TranslationChapterUIState.Loading, mantraRepository: MantraRepository, + onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { val translationChapterViewModel: TranslationChapterViewModel = viewModel( @@ -128,7 +134,20 @@ fun TranslationChapterScreen( items = translationChapterUIState.rows, key = { pair -> pair.originalChunk.id } ) { pair -> - ChunkTranslationRow(pair) + ChunkTranslationRow( + pair = pair, + onClick = { + onNavigateToRoute.invoke( + TranslateChunkRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chunkId = pair.originalChunk.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) HorizontalDivider() } } @@ -162,18 +181,35 @@ fun TranslationChapterScreen( } @Composable -private fun ChunkTranslationRow(pair: ChunkTranslationPair) { +private fun ChunkTranslationRow( + pair: ChunkTranslationPair, + onClick: () -> Unit, +) { val translated = pair.translationChunk?.text?.takeIf { it.isNotBlank() } TableRow( left = { Text(pair.originalChunk.text) }, right = { - if (translated != null) { - Text(translated) - } else { - // No translation yet: show the original as a greyed-out placeholder. + // The translation cell is a button that opens the chunk translation + // editor. When there is no translation yet, the original text is + // shown greyed out as a placeholder. + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(0.dp) + ) { Text( - text = pair.originalChunk.text, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + modifier = Modifier.weight(1f), + text = translated ?: pair.originalChunk.text, + color = if (translated != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + ) + Icon( + Icons.Default.ChevronRight, + contentDescription = "Translate this chunk", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } }, @@ -219,6 +255,7 @@ private fun TranslationChapterScreenPreview() { ) ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, onNavigateBack = {} ) } 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 337aebfe..0f432256 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 @@ -93,12 +93,14 @@ import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen import press.mantra.compose.ui.composable.AddTranslationScreen import press.mantra.compose.ui.composable.ChapterDetailScreen +import press.mantra.compose.ui.composable.TranslateChunkScreen import press.mantra.compose.ui.composable.TranslationChapterScreen import press.mantra.compose.ui.composable.TranslationDetailScreen @@ -748,6 +750,35 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + TranslateChunkScreen( + activeUserPublicKey = route.activeUserPublicKey, + translationChapterId = route.translationChapterId, + chunkId = route.chunkId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + // Replace this editor and the stale chapter table beneath it so + // we land on a freshly-loaded table reflecting the saved translation. + navController.navigate(route = actionRoute) { + popUpTo { + inclusive = true + } + } + }, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/TranslateChunkRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/TranslateChunkRoute.kt new file mode 100644 index 00000000..7a622729 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/TranslateChunkRoute.kt @@ -0,0 +1,12 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class TranslateChunkRoute( + val activeUserPublicKey: String, + val translationChapterId: String, + val chunkId: String, + val chatRoomId: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt new file mode 100644 index 00000000..82be3afb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslateChunkViewModel.kt @@ -0,0 +1,116 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +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.TranslateChunkUIState + +class TranslateChunkViewModel( + val translationChapterId: String, + val chunkId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialTranslateChunkUIState: TranslateChunkUIState, + val mantraRepository: MantraRepository, +): ViewModel() { + + var translateChunkUIState: TranslateChunkUIState by mutableStateOf(initialTranslateChunkUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateTranslateChunk() { + viewModelScope.launch(Dispatchers.IO) { + val originalChunk = mantraRepository.getChunk(chunkId) + translateChunkUIState = if (originalChunk == null) { + TranslateChunkUIState.Error("Couldn't find the chunk") + } else { + val existing = mantraRepository.getTranslationChunks(translationChapterId) + .firstOrNull { it.chunkId == chunkId } + TranslateChunkUIState.Loaded( + originalChunk = originalChunk, + existingTranslationText = existing?.text.orEmpty(), + ) + } + } + } + + fun saveTranslation( + translationField: TextFieldState, + onSuccess: () -> Unit, + onFailure: () -> Unit + ) { + val text = translationField.text.toString() + if (text.isBlank()) { + onFailure.invoke() + return + } + + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val saved = runCatching { + mantraRepository.saveTranslationChunk( + translationChapterId = translationChapterId, + chunkId = chunkId, + text = text, + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey, + ) + }.onFailure { error -> + logger.e("Failed to save translation", error) + }.getOrNull() + + if (saved != null) { + onSuccess.invoke() + } else { + onFailure.invoke() + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "TranslateChunkViewModel" + + fun factory( + activeUserPublicKey: HexKey, + translationChapterId: String, + chunkId: String, + chatRoomId: String, + relayHint: String?, + initialTranslateChunkUIState: TranslateChunkUIState = TranslateChunkUIState.Loading, + mantraRepository: MantraRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + TranslateChunkViewModel( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chunkId = chunkId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialTranslateChunkUIState = initialTranslateChunkUIState, + mantraRepository = mantraRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt new file mode 100644 index 00000000..027fa3be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslateChunkUIState.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraChunk + +sealed interface TranslateChunkUIState { + data class Loaded( + val originalChunk: MantraChunk, + val existingTranslationText: String = "", + ): TranslateChunkUIState + + data class Error( + val message: String + ): TranslateChunkUIState + + data object Loading: TranslateChunkUIState +}