From 0727a04bae90056abe8c09263da50d1f887687ee Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:19:22 +0200 Subject: [PATCH] feat(dialects): a dialect is a thing you can open Artifacts, translations and projects each have a route, a state, a view model and a screen. A dialect had a `ListItem` inside `ChatRoomDetailScreen`'s `LazyColumn` with no `onClick`, which made it the one entity in a room a member could look at and not open. This is that screen, and the card finally gets the `onClick` it never had. The two lists are the reason it exists. A dialect is a name, a language code and a country; those three sit at the bottom under the details the way they do everywhere else, and what a member opens a dialect to find out is what the group has written in it. `MantraArtifact.dialectId` and `MantraTranslationArtifactVersion.dialectId` are the only two columns in the app that point at a dialect, and neither had a query. Both new queries are scoped by the dialect alone, with no `chatRoomId` beside it. `dialectId` is a foreign key into this room's own rows, so the key already confines the read to one room and a second filter would be a second spelling of a constraint that is already enforced. That is the one-room scope decision made load-bearing rather than restated; when a dialect can be named across rooms -- Phase 9 of docs/project-feature.md -- these two queries are among the places that have to learn about it, which is why the reasoning is on them rather than here. `Loaded` also carries `canAddSubgroup`, which Phase 3 needs and which is the same two conditions `ChatRoomDetailUIState` already states: a Marmot room, and a share of its key. It reads the room off the *dialect's* `chatRoomId` rather than the route's, so the parent of a subgroup made here is the room the dialect actually lives in even if a route is ever built by hand. A missing dialect is `Error` and not an empty `Loaded`, because "this room has written nothing in Sesotho" and "there is no such dialect here" would otherwise look identical and only one of them is worth a retry button. The test that earns its place is the two-dialects-in-one-room case: if either query ever drifted to scoping by room, every single-dialect test would still pass and that one would not. Phase 2 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 3 + .../compose/database/dao/MantraArtifactDao.kt | 13 + .../MantraTranslationArtifactVersionDao.kt | 16 + .../repository/DatabaseMantraRepository.kt | 8 + .../compose/repository/MantraRepository.kt | 14 + .../ui/composable/ChatRoomDetailScreen.kt | 22 +- .../ui/composable/DialectDetailScreen.kt | 317 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 ++ .../navigation/routes/DialectDetailRoute.kt | 19 ++ .../ui/view/model/DialectDetailViewModel.kt | 99 ++++++ .../ui/view/state/DialectDetailUIState.kt | 47 +++ .../ui/view/model/DialectDetailJvmTest.kt | 203 +++++++++++ 12 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a0aa8b78..b52173d7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -89,6 +89,7 @@ Currently no messages have been shared.\nBreak the ice. Delete group Details + Dialect detail Dialect name Dialects Direct message detail @@ -183,6 +184,8 @@ No one selected yet No one to add yet. No projects exists in this group. + No artifacts are written in this dialect yet. + Nothing has been translated into this dialect yet. No translations yet. No versions. Not now diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt index b9cc1758..ac99493e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt @@ -15,4 +15,17 @@ interface MantraArtifactDao { @Query("SELECT * FROM MantraArtifact WHERE id = :id") suspend fun getArtifactById(id: String): MantraArtifact? + + /** + * The artifacts written in [dialectId], newest first. + * + * Scoped by the dialect alone and not also by a room. `dialectId` is a + * foreign key into this room's own rows, so it cannot reach another room's + * artifacts and a second `chatRoomId` filter would be a second spelling of a + * constraint the key already enforces. The day a dialect can be named across + * rooms -- see Phase 9 of docs/project-feature.md -- this is one of the + * places that has to learn about it. + */ + @Query("SELECT * FROM MantraArtifact WHERE dialectId = :dialectId ORDER BY createdAt DESC") + suspend fun getArtifactsByDialectId(dialectId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt index 160d5960..9d57e584 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt @@ -41,4 +41,20 @@ interface MantraTranslationArtifactVersionDao { @Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id") suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion? + + /** + * The translations into [dialectId], newest first. + * + * Scoped by the dialect alone, for the reason + * `MantraArtifactDao.getArtifactsByDialectId` gives: the foreign key already + * confines it to one room. + */ + @Query( + """ + SELECT * FROM MantraTranslationArtifactVersion + WHERE dialectId = :dialectId + ORDER BY createdAt DESC + """ + ) + suspend fun getTranslationsByDialectId(dialectId: String): List } \ 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 8eafb679..c90a101f 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 @@ -104,6 +104,14 @@ class DatabaseMantraRepository( override suspend fun getDialect(id: String): MantraDialect? = database.mantraDialectDao().getDialectById(id) + override suspend fun getArtifactsForDialect(dialectId: String): List = + database.mantraArtifactDao().getArtifactsByDialectId(dialectId) + + override suspend fun getTranslationsForDialect( + dialectId: String + ): List = + database.mantraTranslationArtifactVersionDao().getTranslationsByDialectId(dialectId) + override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, 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 3d197ad5..284b8971 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -57,6 +57,12 @@ interface MantraRepository { suspend fun getDialect(id: String): MantraDialect? + /** The artifacts written in a dialect, newest first. */ + suspend fun getArtifactsForDialect(dialectId: String): List + + /** The translations into a dialect, newest first. */ + suspend fun getTranslationsForDialect(dialectId: String): List + suspend fun getProjects(chatRoomId: String): List suspend fun getProject(id: String): MantraProject? @@ -128,6 +134,14 @@ interface MantraRepository { override suspend fun getDialect(id: String): MantraDialect? = null + override suspend fun getArtifactsForDialect( + dialectId: String + ): List = emptyList() + + override suspend fun getTranslationsForDialect( + dialectId: String + ): List = emptyList() + override suspend fun getProjects(chatRoomId: String): List = emptyList() override suspend fun getProject(id: String): MantraProject? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 0800c27d..609005de 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -105,6 +105,7 @@ import mantra.composeapp.generated.resources.subgroups import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager import mantra.composeapp.generated.resources.Res @@ -566,12 +567,29 @@ fun ChatRoomDetailScreen( items = chatRoomDetailUIState.dialects, key = { dialect -> dialect.id } ) { dialect -> - Card { + Card( + onClick = { + onNavigateToRoute.invoke( + DialectDetailRoute( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialect.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { ListItem( leadingContent = { Icon( Icons.Default.Translate, - contentDescription = "Dialect" + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative ) }, headlineContent = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt new file mode 100644 index 00000000..b60f1557 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -0,0 +1,317 @@ +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LibraryBooks +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +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.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.country +import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.dialect_detail +import mantra.composeapp.generated.resources.language +import mantra.composeapp.generated.resources.library +import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect +import mantra.composeapp.generated.resources.no_translations_are_into_this_dialect +import mantra.composeapp.generated.resources.translations +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.repository.ChatRepository +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.navigation.routes.TranslationArtifactVersionDetailRoute +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.DetailRow +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.DialectDetailViewModel +import press.mantra.compose.ui.view.state.DialectDetailUIState + +/** + * A dialect, and what the group has written in it. + * + * The two lists are the screen. A dialect is a name, a language code and a + * country, and those three are at the bottom under the details the way they are + * everywhere else; what a member opens a dialect for is the artifacts written in + * it and the translations into it, which are the only two things in this app that + * point at a dialect at all. + * + * Until this screen the dialect card in `ChatRoomDetailScreen` had no `onClick`, + * which made a dialect the one entity in a room a member could not open. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DialectDetailScreen( + activeUserPublicKey: HexKey, + dialectId: String, + chatRoomId: String, + relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val dialectDetailViewModel: DialectDetailViewModel = viewModel( + factory = DialectDetailViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialDialectDetailUIState = initialDialectDetailUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + ) + ) + + ScreenStateTransition(dialectDetailViewModel.dialectDetailUIState) { uiState -> + when (val dialectDetailUIState = uiState) { + is DialectDetailUIState.Error -> ErrorState( + message = dialectDetailUIState.message, + // Reloading is worth offering even though the usual cause is a + // dialect this device does not hold: a room's rows arrive on the + // group's schedule, so the second attempt can genuinely find what + // the first could not. + onRetry = { dialectDetailViewModel.initiateDialectDetail() } + ) + + is DialectDetailUIState.Loaded -> { + val dialect = dialectDetailUIState.dialect + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(dialect.name) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + item { + Text( + text = stringResource(Res.string.library), + style = MaterialTheme.typography.labelMedium + ) + } + + if (dialectDetailUIState.artifacts.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.no_artifacts_are_written_in_this_dialect + ), + icon = Icons.Default.LibraryBooks, + ) + } + } else { + items( + items = dialectDetailUIState.artifacts, + key = { artifact -> artifact.id } + ) { artifact -> + Card( + onClick = { + onNavigateToRoute.invoke( + ArtifactDetailRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.LibraryBooks, + contentDescription = Decorative + ) + }, + headlineContent = { Text(artifact.name) }, + supportingContent = { Text(artifact.url) } + ) + } + } + } + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + } + + if (dialectDetailUIState.translations.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.no_translations_are_into_this_dialect + ), + icon = Icons.Default.Translate, + ) + } + } else { + items( + items = dialectDetailUIState.translations, + key = { translation -> translation.id } + ) { translation -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationArtifactVersionDetailRoute( + activeUserPublicKey = activeUserPublicKey, + translationArtifactVersionId = translation.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + headlineContent = { Text(translation.name) }, + supportingContent = { Text(translation.visibility) } + ) + } + } + } + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + Card { + // The pair that is actually the dialect's + // identity, and the name that is not: nothing in + // the schema makes a dialect unique, so two rooms + // naming `st`-in-Lesotho differently hold the same + // dialect under two names. + DetailRow( + label = stringResource(Res.string.language), + value = dialect.language + ) + DetailRow( + label = stringResource(Res.string.country), + value = dialect.country + ) + } + } + } + } + } + + DialectDetailUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.containerPadding + ) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.dialect_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialDialectDetailUIState == DialectDetailUIState.Loading) { + dialectDetailViewModel.initiateDialectDetail() + } + } +} + +@ConformancePreviews +@Composable +private fun DialectDetailScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DialectDetailScreen( + activeUserPublicKey = "", + dialectId = "dialectId", + chatRoomId = "chatRoomId", + relayHint = null, + initialDialectDetailUIState = DialectDetailUIState.Loaded( + dialect = MantraDialect( + id = "dialectId", + publicKey = "author", + name = "Sesotho", + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = "chatRoomId", + ) + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_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 444eaec2..9e1e5728 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 @@ -151,6 +151,7 @@ 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.ProjectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute 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.TranslationArtifactVersionDetailRoute @@ -165,6 +166,7 @@ import press.mantra.compose.ui.composable.FrostSigningScreen import press.mantra.compose.ui.composable.ProposalListScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen +import press.mantra.compose.ui.composable.DialectDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen import press.mantra.compose.ui.composable.TranslationChapterScreen import press.mantra.compose.ui.composable.TranslationArtifactVersionDetailScreen @@ -1326,6 +1328,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DialectDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + dialectId = route.dialectId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt new file mode 100644 index 00000000..d09acaed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * A dialect, and what is written in it. + * + * [chatRoomId] rides along although the dialect row already names its room, + * because every sibling detail route carries it and the screens pass it on to + * the routes they open. Reading it off the row instead would make this the one + * route that cannot be navigated to before its entity has loaded. + */ +@Serializable +data class DialectDetailRoute( + val activeUserPublicKey: String, + val dialectId: String, + val chatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt new file mode 100644 index 00000000..c5e1b6b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt @@ -0,0 +1,99 @@ +package press.mantra.compose.ui.view.model + +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.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.DialectDetailUIState + +/** + * A dialect, what is written in it, and whether it could have a room of its own. + * + * The two lists are the reason this screen exists: a dialect is a name, a + * language code and a country, and the thing a member opens one to find out is + * what the group has written in it. Both are read by the dialect's id alone -- + * see `MantraArtifactDao.getArtifactsByDialectId` for why no room is named + * beside it. + */ +class DialectDetailViewModel( + val dialectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState, + val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, +): ViewModel() { + + var dialectDetailUIState: DialectDetailUIState by mutableStateOf(initialDialectDetailUIState) + private set + + private val logger = Logger.withTag(TAG) + + fun initiateDialectDetail() { + logger.d("initiateDialectDetail: $dialectId") + viewModelScope.launch(Dispatchers.IO) { + val dialect = mantraRepository.getDialect(dialectId) + + dialectDetailUIState = if (dialect == null) { + // Error rather than an empty `Loaded`, because the two are + // different answers: "this room has written nothing in Sesotho" + // and "there is no such dialect here" would otherwise look + // identical, and only one of them is worth retrying. + DialectDetailUIState.Error("Couldn't find the dialect") + } else { + // The dialect's own room, not the route's. They are the same + // room in every path that exists today, and reading it off the + // row is what keeps that true if a route is ever constructed by + // hand -- the parent of a subgroup made here must be the room + // the dialect actually lives in. + val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId) + + DialectDetailUIState.Loaded( + dialect = dialect, + artifacts = mantraRepository.getArtifactsForDialect(dialectId), + translations = mantraRepository.getTranslationsForDialect(dialectId), + canAddSubgroup = room?.chatRoom?.mlsGroupState != null && + chatRepository.canSign(dialect.chatRoomId), + ) + } + } + } + + companion object { + private const val TAG = "DialectDetailViewModel" + + fun factory( + activeUserPublicKey: HexKey, + dialectId: String, + chatRoomId: String, + relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + DialectDetailViewModel( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialDialectDetailUIState = initialDialectDetailUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt new file mode 100644 index 00000000..0c5c3adf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion + +sealed interface DialectDetailUIState { + data class Loaded( + val dialect: MantraDialect, + + /** + * The artifacts written in this dialect. + * + * This and [translations] are what the screen is for. A dialect on its + * own is three strings -- a name, a language code and a country -- and a + * screen showing only those has no reason to exist. These are the two + * tables that carry a `dialectId`, and between them they answer the only + * question a member opens a dialect to ask. + */ + val artifacts: List = emptyList(), + + /** The translations into this dialect. */ + val translations: List = emptyList(), + + /** + * Whether this device could open a subgroup for this dialect at all. + * + * Two conditions, both about capability rather than permission, and the + * same two `ChatRoomDetailUIState.canAddSubgroup` states: the room has to + * be a Marmot one, since a NIP-17 room has no admins to be a parent's, + * and this device has to hold a share of the group's key, because + * certifying a child is a signature by the parent and + * `FrostSigningManager.proposeSigningBatch` throws for a device that + * cannot make one. + * + * The rest of the refusals are the picker's, where they can name who is + * missing and what to do about it. + */ + val canAddSubgroup: Boolean = false, + ): DialectDetailUIState + + data class Error( + val message: String + ): DialectDetailUIState + + data object Loading: DialectDetailUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt new file mode 100644 index 00000000..9418b1b9 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt @@ -0,0 +1,203 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.ui.view.state.DialectDetailUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What a dialect's screen is looking at, and what it leaves out. + * + * The lists are read by the dialect's id alone -- no room is named beside it, + * because `dialectId` is a foreign key into this room's own rows and a second + * filter would restate a constraint the key already enforces. So the case worth + * having is a room holding *two* dialects: if the query ever drifted to scoping + * by room instead, every test with one dialect in it would still pass and this + * one would not. + * + * The other half is that a missing dialect is an error rather than an empty + * screen. "This room has written nothing in Sesotho" and "there is no such + * dialect here" would otherwise look identical, and only one of them is worth + * offering a retry for. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DialectDetailJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private val otherDialectId = "9".repeat(64) + private val unusedDialectId = "d".repeat(64) + + private fun viewModel(dialectId: String = room.dialectId) = DialectDetailViewModel( + dialectId = dialectId, + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + relayHint = null, + initialDialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository = DatabaseMantraRepository(db, scope), + chatRepository = DatabaseChatRepository(db, scope), + ) + + private suspend fun loaded(dialectId: String = room.dialectId): DialectDetailUIState.Loaded { + val viewModel = viewModel(dialectId) + viewModel.initiateDialectDetail() + return awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Loaded } + } + + /** A second dialect in the same room, with its own artifact and translation. */ + private suspend fun seedSecondDialect() { + db.mantraDialectDao().upsert( + MantraDialect( + id = otherDialectId, + publicKey = room.roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = "8".repeat(64), + publicKey = room.roomId, + name = "Another book", + url = "https://example.invalid/another", + visibility = "private", + dialectId = otherDialectId, + license = "cc", + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "4".repeat(64), + publicKey = room.roomId, + artifactVersionId = room.versionId, + dialectId = otherDialectId, + name = "Setswana Mockingbird", + visibility = "private", + license = "cc", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + } + + @Test + fun `the dialect loads with what is written in it`() = runBlocking { + room.seed() + + val state = loaded() + + assertEquals("Sesotho", state.dialect.name) + assertEquals("st", state.dialect.language) + assertEquals(listOf(room.artifactId), state.artifacts.map { it.id }) + assertEquals(listOf(room.translationId), state.translations.map { it.id }) + } + + /** + * The case that fails if the queries ever scope by room instead of by + * dialect. Both dialects are in one room, so a room-scoped read returns all + * four rows and reads as a working screen. + */ + @Test + fun `another dialect in the same room is left out`() = runBlocking { + room.seed() + seedSecondDialect() + + val sesotho = loaded() + + assertEquals(listOf(room.artifactId), sesotho.artifacts.map { it.id }) + assertEquals(listOf(room.translationId), sesotho.translations.map { it.id }) + + val setswana = loaded(otherDialectId) + + assertEquals(listOf("8".repeat(64)), setswana.artifacts.map { it.id }) + assertEquals(listOf("4".repeat(64)), setswana.translations.map { it.id }) + } + + /** + * The empty state, which is a normal state and not a failure: a room defines + * a dialect before it has anything in it, and that is the whole point of + * being able to define one. + */ + @Test + fun `a dialect nothing is written in loads empty rather than failing`() = runBlocking { + room.seed() + db.mantraDialectDao().upsert( + MantraDialect( + id = unusedDialectId, + publicKey = room.roomId, + name = "isiZulu", + country = "ZA", + language = "zu", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + + val state = loaded(unusedDialectId) + + assertEquals("isiZulu", state.dialect.name) + assertTrue(state.artifacts.isEmpty()) + assertTrue(state.translations.isEmpty()) + } + + @Test + fun `a dialect this device does not hold is an error, not an empty screen`() = runBlocking { + room.seed() + + val viewModel = viewModel(dialectId = "c".repeat(64)) + viewModel.initiateDialectDetail() + + val state = awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Error } + + assertEquals("Couldn't find the dialect", state.message) + } + + /** + * The fixture's room has no MLS state, so no subgroup can be made from it -- + * a NIP-17 room has no admins to be a parent's. The flag says so instead of + * the button failing when pressed. + */ + @Test + fun `a room with no group state cannot have a subgroup made for its dialect`() = runBlocking { + room.seed() + + assertFalse(loaded().canAddSubgroup) + } +}