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