diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index a0aa8b78..93b2a8d3 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -569,4 +569,16 @@
Abandoned. No key was created.
%1$s of %2$s members will sign with it
Set up a shared key
+
+ Add dialects from a list
+ Pick the languages this group will translate into. Ones it already has, and ones it is already signing, cannot be picked again.
+ Search by language, country or code
+ %1$s of %2$s selected
+ Select everything missing
+ Clear the selection
+ Propose %1$s dialects
+ Already in this group
+ Waiting for the group to sign this one
+ This group already has a dialect for every language in the list. Add one it does not list with the form.
+ This group has no shared key, so it cannot sign a dialect into existence. Run a shared key ceremony first.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt
index 97e8e4d7..6485a573 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt
@@ -60,6 +60,7 @@ import mantra.composeapp.generated.resources.eg_sesotho
import mantra.composeapp.generated.resources.eg_st
import mantra.composeapp.generated.resources.language
import mantra.composeapp.generated.resources.propose_dialect
+import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot_sign_a_dialect
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
@@ -179,8 +180,10 @@ fun AddDialectScreen(
if (!addDialectUIState.canSign) {
Text(
- text = "This group has no shared key, so it cannot sign a " +
- "dialect into existence. Run a shared key ceremony first.",
+ text = stringResource(
+ Res.string
+ .this_group_has_no_shared_key_so_it_cannot_sign_a_dialect
+ ),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt
new file mode 100644
index 00000000..0cf848c6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt
@@ -0,0 +1,433 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Translate
+import androidx.compose.material3.Card
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.add_dialect
+import mantra.composeapp.generated.resources.add_dialects_from_a_list
+import mantra.composeapp.generated.resources.already_in_this_group
+import mantra.composeapp.generated.resources.clear_the_selection
+import mantra.composeapp.generated.resources.nothing_matched_that_search
+import mantra.composeapp.generated.resources.pick_the_languages_this_group_will_translate_into
+import mantra.composeapp.generated.resources.s_of_s_selected
+import mantra.composeapp.generated.resources.search_by_language_country_or_code
+import mantra.composeapp.generated.resources.select_everything_missing
+import mantra.composeapp.generated.resources.this_group_already_has_every_language_in_the_list
+import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot_sign_a_dialect
+import mantra.composeapp.generated.resources.waiting_for_the_group_to_sign_this_one
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.language.AfricanLanguages
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.repository.MantraRepository
+import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
+import press.mantra.compose.ui.composable.navigation.routes.Route
+import press.mantra.compose.ui.composable.widgets.Decorative
+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.AddDialectsFromCatalogueViewModel
+import press.mantra.compose.ui.view.state.AddDialectsFromCatalogueUIState
+import press.mantra.compose.ui.view.state.CatalogueRow
+import press.mantra.compose.ui.view.state.matches
+
+/**
+ * Picking dialects out of the catalogue of African languages.
+ *
+ * Shows every entry and what this room can still ask for. A language the room
+ * holds and a language it is already signing are both shown and neither can be
+ * ticked, with different words on each, because "not available" has two
+ * explanations and telling a member the wrong one is worse than telling them
+ * nothing: one is theirs already, and the other is a proposal somebody else
+ * opened that they can go and sign.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun AddDialectsFromCatalogueScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ initialUIState: AddDialectsFromCatalogueUIState = AddDialectsFromCatalogueUIState.Loading,
+ chatRepository: ChatRepository,
+ mantraRepository: MantraRepository,
+ frostSigningRepository: FrostSigningRepository,
+ onNavigateToRoute: (Route) -> Unit,
+ onNavigateBack: () -> Unit,
+) {
+ val viewModel: AddDialectsFromCatalogueViewModel = viewModel(
+ factory = AddDialectsFromCatalogueViewModel.factory(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ initialUIState = initialUIState,
+ chatRepository = chatRepository,
+ mantraRepository = mantraRepository,
+ frostSigningRepository = frostSigningRepository,
+ )
+ )
+
+ val uiState by viewModel.uiState.collectAsState()
+
+ ScreenStateTransition(uiState) { state ->
+ when (state) {
+ is AddDialectsFromCatalogueUIState.Error -> ErrorState(
+ message = state.message,
+ // A real retry: the state came from a database read that can
+ // succeed on a second try, not from a navigation argument that
+ // was already wrong.
+ onRetry = viewModel::initiate,
+ )
+
+ is AddDialectsFromCatalogueUIState.Loaded -> {
+ // imePadding: the search field is on this screen, and without it
+ // the software keyboard covers what is being typed into.
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ modifier = Modifier.imePadding(),
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(Res.string.add_dialects_from_a_list)) },
+ navigationIcon = { NavigateBackButton(onNavigateBack) },
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
+ ) {
+ Text(
+ stringResource(
+ Res.string.pick_the_languages_this_group_will_translate_into
+ )
+ )
+
+ if (!state.canSign) {
+ Text(
+ text = stringResource(
+ Res.string
+ .this_group_has_no_shared_key_so_it_cannot_sign_a_dialect
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ OutlinedTextField(
+ value = viewModel.query.value,
+ onValueChange = viewModel::setQuery,
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = {
+ Icon(Icons.Default.Search, contentDescription = Decorative)
+ },
+ label = {
+ Text(
+ stringResource(Res.string.search_by_language_country_or_code)
+ )
+ },
+ )
+
+ when {
+ // Loaded is reached before the rows are read, so an
+ // empty list is "still reading" rather than "nothing".
+ state.rows.isEmpty() -> LoadingDataIndicator(fillScreen = false)
+
+ state.missing.isEmpty() -> EmptyState(
+ message = stringResource(
+ Res.string
+ .this_group_already_has_every_language_in_the_list
+ ),
+ icon = Icons.Default.Translate,
+ action = {
+ TextButton(
+ onClick = {
+ onNavigateToRoute(
+ AddDialectRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ )
+ )
+ }
+ ) {
+ Text(stringResource(Res.string.add_dialect))
+ }
+ },
+ )
+
+ else -> {
+ SelectionSummary(
+ selected = viewModel.selected.size,
+ missing = state.missing.size,
+ onSelectEverythingMissing = viewModel::selectEverythingMissing,
+ onClearSelection = viewModel::clearSelection,
+ )
+
+ CatalogueList(state = state, viewModel = viewModel)
+ }
+ }
+ }
+ }
+ }
+
+ AddDialectsFromCatalogueUIState.Loading -> LoadingDataIndicator(fillScreen = true)
+ }
+ }
+
+ LaunchedEffect(true) {
+ if (initialUIState == AddDialectsFromCatalogueUIState.Loading) {
+ viewModel.initiate()
+ }
+ }
+}
+
+/**
+ * How much is ticked, and the two ways to change all of it at once.
+ *
+ * The count is of what is missing rather than of the catalogue, because the
+ * catalogue's size is not a number a member can act on: "12 of 40 selected" says
+ * how much is left to pick, and "12 of 56" would count rows they cannot tick.
+ */
+@Composable
+private fun SelectionSummary(
+ selected: Int,
+ missing: Int,
+ onSelectEverythingMissing: () -> Unit,
+ onClearSelection: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
+ ) {
+ Text(
+ text = stringResource(Res.string.s_of_s_selected, selected, missing),
+ style = MaterialTheme.typography.labelMedium,
+ )
+
+ if (selected == 0) {
+ TextButton(onClick = onSelectEverythingMissing) {
+ Text(stringResource(Res.string.select_everything_missing))
+ }
+ } else {
+ TextButton(onClick = onClearSelection) {
+ Text(stringResource(Res.string.clear_the_selection))
+ }
+ }
+ }
+}
+
+/** The catalogue, filtered by the search and grouped under the country. */
+@Composable
+private fun CatalogueList(
+ state: AddDialectsFromCatalogueUIState.Loaded,
+ viewModel: AddDialectsFromCatalogueViewModel,
+) {
+ val query = viewModel.query.value
+ val rows = state.rows.filter { it.language.matches(query) }
+
+ if (rows.isEmpty()) {
+ EmptyState(
+ message = stringResource(Res.string.nothing_matched_that_search),
+ icon = Icons.Default.Search,
+ )
+ return
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50),
+ ) {
+ rows.forEachIndexed { index, row ->
+ val language = row.language
+
+ // The country, once, above the languages spoken there. A heading per
+ // row would say "Nigeria" four times.
+ if (rows.getOrNull(index - 1)?.language?.country != language.country) {
+ item(key = "country ${language.country}") {
+ Text(
+ text = language.country,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(top = MaterialTheme.spacing.space125),
+ )
+ }
+ }
+
+ item(key = "${language.code} ${language.country}") {
+ LanguageRow(
+ row = row,
+ isSelected = viewModel.isSelected(language),
+ onToggle = { viewModel.toggle(language) },
+ )
+ }
+ }
+ }
+}
+
+/**
+ * One catalogue entry.
+ *
+ * A clickable `Card` rather than a bare clickable modifier, so the whole row is
+ * the target and is 48dp without anybody having to remember
+ * `minimumInteractiveComponentSize` -- the same shape `SelectSubgroupAdminsScreen`
+ * uses for picking members. (Spelled this way round because
+ * `m3-touch-targets.py` reads comments as code; see the note in m3-audit.sh on
+ * the colour check, which hit the same thing.)
+ */
+@Composable
+private fun LanguageRow(
+ row: CatalogueRow,
+ isSelected: Boolean,
+ onToggle: () -> Unit,
+) {
+ val missing = row.standing == CatalogueRow.Standing.MISSING
+
+ Card(onClick = onToggle, enabled = missing) {
+ ListItem(
+ leadingContent = {
+ // Decorative: the name is beside it and the checkbox carries the
+ // state.
+ Icon(Icons.Default.Translate, contentDescription = Decorative)
+ },
+ headlineContent = {
+ Text(
+ text = row.language.name,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ // Which of the two reasons it cannot be ticked, since they are
+ // different things to do about it. The code otherwise, which is
+ // what a member searching by ISO 639 is looking for.
+ Text(
+ text = when (row.standing) {
+ CatalogueRow.Standing.MISSING -> row.language.code
+ CatalogueRow.Standing.IN_THE_ROOM ->
+ stringResource(Res.string.already_in_this_group)
+
+ CatalogueRow.Standing.IN_FLIGHT ->
+ stringResource(Res.string.waiting_for_the_group_to_sign_this_one)
+ }
+ )
+ },
+ trailingContent = {
+ Checkbox(
+ checked = isSelected || row.standing == CatalogueRow.Standing.IN_THE_ROOM,
+ enabled = missing,
+ onCheckedChange = if (missing) {
+ { onToggle() }
+ } else {
+ null
+ },
+ )
+ }
+ )
+ }
+}
+
+private val previewRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = "",
+ userPublicKey = "",
+ subject = "Message title",
+ description = "See something. Say something",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = null,
+ ),
+)
+
+@ConformancePreviews
+@Composable
+private fun AddDialectsFromCataloguePreview() {
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ AddDialectsFromCatalogueScreen(
+ activeUserPublicKey = "",
+ chatRoomId = "publicKey",
+ relayHint = null,
+ initialUIState = AddDialectsFromCatalogueUIState.Loaded(
+ localChatRoom = previewRoom,
+ canSign = true,
+ rows = CatalogueRow.rowsOf(
+ existing = emptyList(),
+ pending = listOf(AfricanLanguages.ALL.first()),
+ ),
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ onNavigateToRoute = {},
+ onNavigateBack = {},
+ )
+ }
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun AddDialectsFromCatalogueNothingLeftPreview() {
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ AddDialectsFromCatalogueScreen(
+ activeUserPublicKey = "",
+ chatRoomId = "publicKey",
+ relayHint = null,
+ initialUIState = AddDialectsFromCatalogueUIState.Loaded(
+ localChatRoom = previewRoom,
+ canSign = true,
+ rows = CatalogueRow.rowsOf(
+ existing = emptyList(),
+ pending = AfricanLanguages.ALL,
+ ),
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ onNavigateToRoute = {},
+ onNavigateBack = {},
+ )
+ }
+ }
+}
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 d5af2cba..5c9310a8 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
@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.ChevronRight
@@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
+import press.mantra.compose.ui.composable.navigation.routes.AddDialectsFromCatalogueRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute
import press.mantra.compose.ui.theme.spacing
@@ -111,6 +113,7 @@ import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.add_artifact_to_library
import mantra.composeapp.generated.resources.add_dialect
+import mantra.composeapp.generated.resources.add_dialects_from_a_list
import mantra.composeapp.generated.resources.create_project
import mantra.composeapp.generated.resources.delete_group
import mantra.composeapp.generated.resources.dialects
@@ -580,6 +583,37 @@ fun ChatRoomDetailScreen(
}
}
+ item {
+ // The catalogue, beside the form rather than
+ // instead of it: the form makes one dialect the
+ // group names itself, and this picks a wave of
+ // them out of the languages most spoken in
+ // Africa. See docs/african-dialect-catalogue.md.
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ AddDialectsFromCatalogueRoute(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = activeUserPublicKey,
+ relayHint = relayHint
+ )
+ )
+ }
+ ) {
+
+ Icon(
+ Icons.Default.Language,
+ contentDescription = Decorative
+ )
+
+ Spacer(
+ modifier = Modifier.width(MaterialTheme.spacing.space125)
+ )
+
+ Text(stringResource(Res.string.add_dialects_from_a_list))
+ }
+ }
+
item {
HorizontalDivider()
}
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..449c5826 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
@@ -141,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute
import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
+import press.mantra.compose.ui.composable.navigation.routes.AddDialectsFromCatalogueRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute
@@ -161,6 +162,7 @@ import press.mantra.compose.ui.composable.ProjectDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.UploadChaptersScreen
import press.mantra.compose.ui.composable.AddDialectScreen
+import press.mantra.compose.ui.composable.AddDialectsFromCatalogueScreen
import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.ProposalListScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
@@ -1240,6 +1242,24 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddDialectsFromCatalogueScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ chatRepository = databaseChatRepository,
+ mantraRepository = databaseMantraRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ 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/AddDialectsFromCatalogueRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt
new file mode 100644
index 00000000..4995f06e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt
@@ -0,0 +1,19 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Picking dialects out of the bundled catalogue instead of typing one in.
+ *
+ * The same three arguments as [AddDialectRoute], and a separate destination
+ * rather than a mode on that one: the form takes three text fields and makes one
+ * dialect, and this takes a selection and makes a wave of them. One screen doing
+ * both would have a `Loaded` state carrying the fields *and* the selection, and a
+ * button meaning two different things.
+ */
+@Serializable
+data class AddDialectsFromCatalogueRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String,
+ val relayHint: String?
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt
new file mode 100644
index 00000000..3fc835f3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt
@@ -0,0 +1,209 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+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.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import press.mantra.compose.language.AfricanLanguage
+import press.mantra.compose.managers.DialectScaffold
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.repository.MantraRepository
+import press.mantra.compose.ui.view.state.AddDialectsFromCatalogueUIState
+import press.mantra.compose.ui.view.state.CatalogueRow
+
+/**
+ * Picking dialects out of the catalogue rather than typing one in.
+ *
+ * Reads what the room holds and what it is already signing, and offers the rest.
+ * The second half of that is the part worth having: without it two admins open
+ * this screen, both see the same forty missing and both propose, and the room
+ * gets eighty dialects -- forty of them duplicates nothing will ever merge,
+ * because nothing in the schema makes a dialect unique.
+ */
+class AddDialectsFromCatalogueViewModel(
+ val chatRoomId: String,
+ val activeUserPublicKey: HexKey,
+ val relayHint: String?,
+ initialUIState: AddDialectsFromCatalogueUIState,
+ val chatRepository: ChatRepository,
+ val mantraRepository: MantraRepository,
+ val frostSigningRepository: FrostSigningRepository,
+): ViewModel() {
+
+ /**
+ * A `StateFlow` rather than the `mutableStateOf` the neighbouring view models
+ * use, and not for consistency's sake.
+ *
+ * Those write their state once. This one is written from `Dispatchers.IO` on
+ * the way in *and* again from a collector on the room's signing sessions,
+ * which is the case where a `mutableStateOf` in a view model drops a write
+ * made off the main thread during the first composition. `update { }` over a
+ * `MutableStateFlow` is the shape that does not.
+ */
+ private val _uiState = MutableStateFlow(initialUIState)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val logger = Logger.withTag(TAG)
+
+ val isActionPending: MutableState = mutableStateOf(false)
+
+ /**
+ * What is ticked, by the only thing that identifies a dialect.
+ *
+ * Keys rather than entries so that a row arriving from a refresh is the same
+ * selection as the one that was ticked, and a `mutableStateListOf` rather
+ * than a field of the UI state because the UI read-modify-writes it on every
+ * tap -- see the note on [_uiState].
+ */
+ val selected = mutableStateListOf()
+
+ val query: MutableState = mutableStateOf("")
+
+ fun initiate() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+
+ if (localChatRoom == null) {
+ _uiState.value = AddDialectsFromCatalogueUIState.Error("Couldn't find this group")
+ return@launch
+ }
+
+ _uiState.value = AddDialectsFromCatalogueUIState.Loaded(
+ localChatRoom = localChatRoom,
+ canSign = frostSigningRepository.canSign(chatRoomId),
+ )
+
+ // The room's sessions drive the refresh, and the rows are re-read
+ // with them rather than observed separately. A wave landing changes
+ // both at once -- its session completes in the same breath as its
+ // dialects become rows -- so one emission covers them, and the
+ // dialect DAO has no flow to observe anyway.
+ //
+ // What this does not catch is a dialect arriving with no session of
+ // this room behind it: a chronicle page, or inbound work from
+ // another device. Those are picked up on the way in rather than
+ // live, which is the right trade for a screen somebody opens,
+ // ticks and leaves.
+ frostSigningRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions ->
+ val pending = DialectScaffold.pendingIn(
+ sessions = sessions,
+ unsignedEvents = frostSigningRepository::unsignedEvents,
+ )
+
+ val rows = CatalogueRow.rowsOf(
+ existing = mantraRepository.getDialects(chatRoomId),
+ pending = pending,
+ )
+
+ _uiState.update { state ->
+ when (state) {
+ is AddDialectsFromCatalogueUIState.Loaded -> state.copy(rows = rows)
+ else -> state
+ }
+ }
+
+ // Anything that stopped being missing while this screen was open
+ // -- another admin proposed it, or a wave landed -- comes off the
+ // selection. Leaving it ticked would propose a dialect the room
+ // already has, and the two would not even collide.
+ val stillMissing = rows
+ .filter { it.standing == CatalogueRow.Standing.MISSING }
+ .mapTo(mutableSetOf()) { DialectScaffold.keyOf(it.language) }
+
+ selected.retainAll(stillMissing)
+ }
+ }
+ }
+
+ /** Ticks or unticks [language]. A row that is not missing is not ours to tick. */
+ fun toggle(language: AfricanLanguage) {
+ if (isActionPending.value) return
+
+ val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return
+ val key = DialectScaffold.keyOf(language)
+
+ if (loaded.rows.none { it.standing == CatalogueRow.Standing.MISSING && DialectScaffold.keyOf(it.language) == key }) {
+ return
+ }
+
+ if (!selected.remove(key)) selected.add(key)
+ }
+
+ fun isSelected(language: AfricanLanguage): Boolean =
+ DialectScaffold.keyOf(language) in selected
+
+ /**
+ * Ticks everything the room is missing -- the whole convenience this screen
+ * is named for.
+ *
+ * Everything missing rather than everything shown: a member who has typed
+ * "so" into the search and taps this means the catalogue, not the three rows
+ * in front of them, and a selection that silently depended on a filter would
+ * be the harder of the two to notice.
+ */
+ fun selectEverythingMissing() {
+ if (isActionPending.value) return
+
+ val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return
+
+ selected.clear()
+ selected.addAll(loaded.missing.map(DialectScaffold::keyOf))
+ }
+
+ fun clearSelection() {
+ if (isActionPending.value) return
+
+ selected.clear()
+ }
+
+ fun setQuery(value: String) {
+ query.value = value
+ }
+
+ /** What is ticked, as catalogue entries in catalogue order. */
+ fun selectedLanguages(): List {
+ val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return emptyList()
+
+ return loaded.missing.filter { DialectScaffold.keyOf(it) in selected }
+ }
+
+ companion object {
+ private const val TAG = "AddDialectsFromCatalogueViewModel"
+
+ fun factory(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ initialUIState: AddDialectsFromCatalogueUIState = AddDialectsFromCatalogueUIState.Loading,
+ chatRepository: ChatRepository,
+ mantraRepository: MantraRepository,
+ frostSigningRepository: FrostSigningRepository,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ AddDialectsFromCatalogueViewModel(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ initialUIState = initialUIState,
+ chatRepository = chatRepository,
+ mantraRepository = mantraRepository,
+ frostSigningRepository = frostSigningRepository,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt
new file mode 100644
index 00000000..5f24b7d8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt
@@ -0,0 +1,117 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.MantraDialect
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.language.AfricanLanguage
+import press.mantra.compose.language.AfricanLanguages
+import press.mantra.compose.managers.DialectScaffold
+
+sealed interface AddDialectsFromCatalogueUIState {
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+
+ /**
+ * Every catalogue entry and where it stands in this room. Loaded is
+ * reached before these are known, so an empty list here means "still
+ * reading" rather than "nothing to show".
+ */
+ val rows: List = emptyList(),
+
+ /**
+ * Whether the group holds a shared key. A dialect is signed into
+ * existence rather than submitted, so a group without one cannot make
+ * any here -- the same gate the hand-typed form has.
+ */
+ val canSign: Boolean = false,
+ ): AddDialectsFromCatalogueUIState {
+ val missing: List
+ get() = rows.filter { it.standing == CatalogueRow.Standing.MISSING }
+ .map { it.language }
+ }
+
+ data class Error(
+ val message: String
+ ): AddDialectsFromCatalogueUIState
+
+ data object Loading: AddDialectsFromCatalogueUIState
+}
+
+/**
+ * A catalogue entry, and whether this room can still ask for it.
+ *
+ * Three standings rather than two, because "not available" has two different
+ * explanations and telling a member the wrong one is worse than telling them
+ * nothing: a dialect the room holds is theirs, and a dialect in flight is a
+ * proposal somebody else opened and they can go and sign.
+ */
+data class CatalogueRow(
+ val language: AfricanLanguage,
+ val standing: Standing,
+) {
+ enum class Standing {
+ /** Nothing holds it and nothing is signing it. The only tickable state. */
+ MISSING,
+
+ /** A `MantraDialect` row for this (code, country) is already here. */
+ IN_THE_ROOM,
+
+ /** An open signing session is signing it. See `DialectScaffold.pendingIn`. */
+ IN_FLIGHT,
+ }
+
+ companion object {
+ /**
+ * Every catalogue entry, standing against what this room holds and what
+ * it is signing.
+ *
+ * Derived from [DialectScaffold.missingFrom] rather than computed beside
+ * it, deliberately. That function is what the proposal is built from, so
+ * a row saying MISSING where it disagreed would offer a member a dialect
+ * the room already has -- and the duplicate would not even collide,
+ * because dialect ids differ by `createdAt`. One authority; the other two
+ * standings are only labels on what it already excluded.
+ */
+ fun rowsOf(
+ existing: List,
+ pending: List,
+ catalogue: List = AfricanLanguages.ALL,
+ ): List {
+ val missing = DialectScaffold
+ .missingFrom(existing = existing, pending = pending, catalogue = catalogue)
+ .mapTo(mutableSetOf(), DialectScaffold::keyOf)
+
+ val held = existing.mapTo(mutableSetOf(), DialectScaffold::keyOf)
+
+ return catalogue.map { language ->
+ val key = DialectScaffold.keyOf(language)
+
+ CatalogueRow(
+ language = language,
+ standing = when {
+ key in missing -> Standing.MISSING
+ key in held -> Standing.IN_THE_ROOM
+ else -> Standing.IN_FLIGHT
+ },
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Whether [query] picks this language out, matched on its name, its country and
+ * its code.
+ *
+ * The code is matched too because it is what a member who knows ISO 639 will
+ * type, and because it is the one field of the three that is not a matter of
+ * spelling -- somebody looking for Sepedi may type `nso` rather than guess
+ * whether this catalogue calls it Sepedi or Northern Sotho.
+ */
+fun AfricanLanguage.matches(query: String): Boolean {
+ val needle = query.trim().lowercase()
+
+ return needle.isEmpty() ||
+ name.lowercase().contains(needle) ||
+ country.lowercase().contains(needle) ||
+ code.lowercase().contains(needle)
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt
new file mode 100644
index 00000000..afd703ec
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt
@@ -0,0 +1,102 @@
+package press.mantra.compose.ui.view.state
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import press.mantra.compose.database.model.MantraDialect
+import press.mantra.compose.language.AfricanLanguage
+
+/**
+ * Where each catalogue entry stands in a room, and what the search picks out.
+ *
+ * The standing is derived from `DialectScaffold.missingFrom` rather than
+ * computed beside it, so the one case worth asserting hard is that a row never
+ * says MISSING where that function disagrees: offering a member a dialect the
+ * room already has would make a duplicate that cannot be merged, since dialect
+ * ids differ by `createdAt`.
+ */
+class CatalogueRowTest {
+
+ private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho")
+ private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya")
+ private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania")
+
+ private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania)
+
+ private fun dialect(language: AfricanLanguage) = MantraDialect(
+ id = "d${language.code}${language.country}".padEnd(64, 'f').take(64),
+ publicKey = "p".repeat(64),
+ name = language.name,
+ country = language.country,
+ language = language.code,
+ signature = "",
+ chatRoomId = "r".repeat(64),
+ )
+
+ @Test
+ fun anEmptyRoomMakesEverythingMissing() {
+ val rows = CatalogueRow.rowsOf(
+ existing = emptyList(),
+ pending = emptyList(),
+ catalogue = catalogue,
+ )
+
+ assertEquals(catalogue, rows.map { it.language })
+ assertTrue(rows.all { it.standing == CatalogueRow.Standing.MISSING })
+ }
+
+ @Test
+ fun eachOfTheThreeStandingsIsReached() {
+ val rows = CatalogueRow.rowsOf(
+ existing = listOf(dialect(sesotho)),
+ pending = listOf(swahiliKenya),
+ catalogue = catalogue,
+ )
+
+ assertEquals(
+ listOf(
+ CatalogueRow.Standing.IN_THE_ROOM,
+ CatalogueRow.Standing.IN_FLIGHT,
+ CatalogueRow.Standing.MISSING,
+ ),
+ rows.map { it.standing }
+ )
+ }
+
+ /**
+ * A dialect that is both held and being signed again reads as held. That is
+ * the more useful of the two -- it is theirs, whatever else is going on --
+ * and either way it is not MISSING, which is the part that matters.
+ */
+ @Test
+ fun heldBeatsInFlight() {
+ val rows = CatalogueRow.rowsOf(
+ existing = listOf(dialect(sesotho)),
+ pending = listOf(sesotho),
+ catalogue = catalogue,
+ )
+
+ assertEquals(CatalogueRow.Standing.IN_THE_ROOM, rows.first().standing)
+ }
+
+ @Test
+ fun theOrderIsTheCataloguesAndEveryEntryIsThere() {
+ val rows = CatalogueRow.rowsOf(
+ existing = listOf(dialect(swahiliKenya)),
+ pending = listOf(swahiliTanzania),
+ catalogue = catalogue,
+ )
+
+ assertEquals(catalogue, rows.map { it.language })
+ }
+
+ @Test
+ fun searchMatchesNameCountryAndCode() {
+ assertTrue(sesotho.matches("seso"), "the name")
+ assertTrue(sesotho.matches("LESOTHO"), "the country, whatever the case")
+ assertTrue(sesotho.matches(" st "), "the code, trimmed")
+ assertTrue(sesotho.matches(""), "an empty search excludes nothing")
+ assertFalse(sesotho.matches("yoruba"))
+ }
+}