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