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<TranslationChapterRoute>) 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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,4 +14,7 @@ interface MantraChunkDao {
|
||||
|
||||
@Query("SELECT * FROM MantraChunk WHERE chapterId = :chapterId ORDER BY `index` ASC")
|
||||
suspend fun getChunksByChapterId(chapterId: String): List<MantraChunk>
|
||||
|
||||
@Query("SELECT * FROM MantraChunk WHERE id = :id")
|
||||
suspend fun getChunkById(id: String): MantraChunk?
|
||||
}
|
||||
@@ -14,4 +14,7 @@ interface MantraTranslationChunkDao {
|
||||
|
||||
@Query("SELECT * FROM MantraTranslationChunk WHERE translationChapterId = :translationChapterId ORDER BY `index` ASC")
|
||||
suspend fun getTranslationChunksByTranslationChapterId(translationChapterId: String): List<MantraTranslationChunk>
|
||||
|
||||
@Query("DELETE FROM MantraTranslationChunk WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -14,4 +14,7 @@ interface MarmotInnerEventDao {
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
|
||||
|
||||
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -158,6 +158,63 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> =
|
||||
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<MantraTranslationArtifactVersion> =
|
||||
database.mantraTranslationArtifactVersionDao().getTranslationsByArtifactId(artifactId)
|
||||
|
||||
|
||||
@@ -26,6 +26,22 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getChunksForChapter(chapterId: String): List<MantraChunk>
|
||||
|
||||
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<MantraTranslationArtifactVersion>
|
||||
|
||||
suspend fun getTranslation(id: String): MantraTranslationArtifactVersion?
|
||||
@@ -113,6 +129,16 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getChunksForChapter(chapterId: String): List<MantraChunk> = 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<MantraTranslationArtifactVersion> = emptyList()
|
||||
|
||||
override suspend fun getTranslation(id: String): MantraTranslationArtifactVersion? = null
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<TranslateChunkRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<TranslateChunkRoute>()
|
||||
|
||||
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<TranslationChapterRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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<Boolean> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user