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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user