feat(dialects): a screen for picking dialects out of the catalogue

Route, state, view model, screen and the way in from the group's detail screen,
beside "Add dialect" rather than instead of it. It shows and picks; proposing is
the next commit.

Three standings per row, not two. A language the room holds and a language it is
already signing both show 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.

The standing is derived from DialectScaffold.missingFrom rather than computed
beside it. That function is what the proposal will be built from, so a row
saying MISSING where it disagreed would offer a dialect the room already has --
and the duplicate would not even collide, since dialect ids differ by createdAt.
One authority; held-versus-in-flight is only a label on what it already excluded.

The view model holds its state in a StateFlow rather than the mutableStateOf its
neighbours use, and not for consistency's sake: those write once, and this one is
written from Dispatchers.IO on the way in *and* again from a collector on the
room's signing sessions, which is where a mutableStateOf in a view model drops an
off-main-thread write made during the first composition.

Anything that stops being missing while the screen is open comes off the
selection, the way SelectSubgroupAdminsViewModel unpicks a member who turns out
to have no key package. Another admin proposing a language this member had ticked
would otherwise propose it twice.

"Select everything missing" means the catalogue and not the filtered rows: a
member who has typed "so" into the search and taps it means all of it, and a
selection that silently depended on a filter is the harder of the two to notice.

Twelve strings, sentence case, and one of them replaces the literal
AddDialectScreen had been carrying for the same sentence about a group with no
shared key.

A clickable Card rather than a bare clickable modifier, so a row is 48dp without
anybody having to remember minimumInteractiveComponentSize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 22:49:14 +02:00
parent f47db49454
commit 2efe320564
9 changed files with 951 additions and 2 deletions

View File

@@ -569,4 +569,16 @@
<string name="abandoned_no_key_was_created">Abandoned. No key was created.</string>
<string name="s_of_s_members_will_sign_with_it">%1$s of %2$s members will sign with it</string>
<string name="set_up_a_shared_key">Set up a shared key</string>
<!-- Picking dialects out of the bundled catalogue of African languages. -->
<string name="add_dialects_from_a_list">Add dialects from a list</string>
<string name="pick_the_languages_this_group_will_translate_into">Pick the languages this group will translate into. Ones it already has, and ones it is already signing, cannot be picked again.</string>
<string name="search_by_language_country_or_code">Search by language, country or code</string>
<string name="s_of_s_selected">%1$s of %2$s selected</string>
<string name="select_everything_missing">Select everything missing</string>
<string name="clear_the_selection">Clear the selection</string>
<string name="propose_s_dialects">Propose %1$s dialects</string>
<string name="already_in_this_group">Already in this group</string>
<string name="waiting_for_the_group_to_sign_this_one">Waiting for the group to sign this one</string>
<string name="this_group_already_has_every_language_in_the_list">This group already has a dialect for every language in the list. Add one it does not list with the form.</string>
<string name="this_group_has_no_shared_key_so_it_cannot_sign_a_dialect">This group has no shared key, so it cannot sign a dialect into existence. Run a shared key ceremony first.</string>
</resources>

View File

@@ -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
)

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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()
}

View File

@@ -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<AddDialectsFromCatalogueRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDialectsFromCatalogueRoute>()
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<BroadcastGroupSignedEventRoute> { backStackEntry ->
val route = backStackEntry.toRoute<BroadcastGroupSignedEventRoute>()

View File

@@ -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()

View File

@@ -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<AddDialectsFromCatalogueUIState> = _uiState.asStateFlow()
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = 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<DialectScaffold.Key>()
val query: MutableState<String> = 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<AfricanLanguage> {
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,
)
}
}
}
}

View File

@@ -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<CatalogueRow> = 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<AfricanLanguage>
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<MantraDialect>,
pending: List<AfricanLanguage>,
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<CatalogueRow> {
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)
}

View File

@@ -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"))
}
}