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 +}