feat(dialects): propose the ticked dialects as a wave
The FAB, the view model method behind it, and the navigation onto the session it opens. The picker can now do the thing it is for. One session per MAX_BATCH_SIZE entries, the way TranslationArtifactVersionDetailViewModel catches up a translation's missing chapters. The catalogue is smaller than one batch today so the chunking never fires; it is one line, it is the established idiom, and it is what stops the sixty-fifth catalogue entry from being a bug rather than a second session. The jvmTest exercises it anyway, with a synthetic selection of 65, because nothing else will. One timestamp for the lot, so a wave reads as the one act it is. The callback is invoked from Dispatchers.Main, and that is not decoration: it navigates, and navigating off Dispatchers.IO crashes. The handoff belongs in the view model rather than at the call site. A batch is all-or-nothing and a retry has to be a fresh proposal, since the items' nonce seeds have already been published against an aggregate. Both are answered by the rule the screen is built on -- it only ever proposes what is missing -- so asking again after a failed wave lands exactly the entries the failed one did not, which is what makes the signing screen right to say it is safe to ask. The new jvmTest runs the real proposal against a real database and a real key, because the half with the sharp edge is the one the caller cannot see: every event of a wave is re-authored under the group's key at the room's derivation path inside proposeSigningBatch, so the ids the group signs are not the ids dialectsOf produced. It also asserts the round trip the picker depends on -- a proposed wave parses back into the entries that were ticked, through the same fromDialectEvent arm applyInnerEvent will take -- and that missingFrom then stops offering them. Nothing was needed to make a wave readable: ProposedEvent already has its dialect arm, and WhatIsBeingSigned already leads a batch with "N events signed together" and refuses to let any of it be signed unless all of it parsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2efe320564
commit
c03112fbbc
4 files changed
+454
No files matched your search
+76
@@ -9,8 +9,10 @@ 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.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -41,6 +43,8 @@ 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.propose_dialect
|
||||
import mantra.composeapp.generated.resources.propose_s_dialects
|
||||
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
|
||||
@@ -55,6 +59,7 @@ 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.FrostSigningRoute
|
||||
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
|
||||
@@ -63,6 +68,7 @@ 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.composable.widgets.buttons.ExtendedFab
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
import press.mantra.compose.ui.theme.MantraTheme
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
@@ -92,6 +98,7 @@ fun AddDialectsFromCatalogueScreen(
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -130,6 +137,47 @@ fun AddDialectsFromCatalogueScreen(
|
||||
title = { Text(stringResource(Res.string.add_dialects_from_a_list)) },
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) },
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Only where there is something to propose. A room that
|
||||
// holds the whole catalogue gets the empty state and its
|
||||
// way to the form, and "Propose 0 dialects" underneath it
|
||||
// would be a button that can never do anything.
|
||||
if (state.missing.isNotEmpty()) {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ProposeWaveFab(
|
||||
count = viewModel.selected.size,
|
||||
enabled = state.canSign &&
|
||||
viewModel.selected.isNotEmpty(),
|
||||
onClick = {
|
||||
viewModel.proposeSelectedDialects(
|
||||
localChatRoom = state.localChatRoom,
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than
|
||||
// back to the group. Nothing
|
||||
// has been created yet -- the
|
||||
// dialects appear when enough
|
||||
// members sign -- so landing on
|
||||
// a list they are not in would
|
||||
// read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey =
|
||||
activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
@@ -222,6 +270,32 @@ fun AddDialectsFromCatalogueScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign what is ticked.
|
||||
*
|
||||
* Counted in the label because a wave is the thing being proposed and its size
|
||||
* is the whole of what distinguishes one from another -- a member who has
|
||||
* scrolled away from the summary still has to be told how many they are about to
|
||||
* put in front of the group.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProposeWaveFab(
|
||||
count: Int,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ExtendedFab(
|
||||
label = if (count == 1) {
|
||||
stringResource(Res.string.propose_dialect)
|
||||
} else {
|
||||
stringResource(Res.string.propose_s_dialects, count)
|
||||
},
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = Decorative) },
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much is ticked, and the two ways to change all of it at once.
|
||||
*
|
||||
@@ -398,6 +472,7 @@ private fun AddDialectsFromCataloguePreview() {
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
@@ -425,6 +500,7 @@ private fun AddDialectsFromCatalogueNothingLeftPreview() {
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
|
||||
+9
@@ -1252,6 +1252,15 @@ fun MantraNavHost(
|
||||
chatRepository = databaseChatRepository,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this picker so back returns to the group rather
|
||||
// than to a selection whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddDialectsFromCatalogueRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(route = actionRoute)
|
||||
},
|
||||
|
||||
+80
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.language.AfricanLanguage
|
||||
import press.mantra.compose.managers.DialectScaffold
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
@@ -181,6 +184,83 @@ class AddDialectsFromCatalogueViewModel(
|
||||
return loaded.missing.filter { DialectScaffold.keyOf(it) in selected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign the ticked dialects into existence.
|
||||
*
|
||||
* None of them are created here and none of them exist yet. What goes out is
|
||||
* a proposal, and the dialects appear -- on every member's device at once,
|
||||
* authored by the room's own key rather than by whoever ticked them -- when
|
||||
* enough members have signed.
|
||||
*
|
||||
* One session per [FrostSigningManager.MAX_BATCH_SIZE] entries, the same way
|
||||
* `TranslationArtifactVersionDetailViewModel` catches up a translation's
|
||||
* missing chapters. The catalogue is smaller than one batch today, so the
|
||||
* chunking never fires; it is a line, it is the established idiom, and it is
|
||||
* what stops the sixty-fifth catalogue entry from being a bug rather than a
|
||||
* second session. `DialectCatalogueSizeTest` is the tripwire.
|
||||
*
|
||||
* One timestamp for the lot, so a wave reads as the one act it is rather than
|
||||
* as entries that happen to share a minute.
|
||||
*
|
||||
* A batch is all-or-nothing: if one item cannot be aggregated the session
|
||||
* fails and none of them are applied, and a retry has to be a fresh proposal
|
||||
* because the items' nonce seeds have already been published. Both are
|
||||
* answered by the rule this screen is built on -- it only ever proposes what
|
||||
* is missing -- so asking again after a failed wave lands exactly the
|
||||
* entries the failed one did not, and the signing screen is right to tell a
|
||||
* member it is safe to ask.
|
||||
*/
|
||||
fun proposeSelectedDialects(
|
||||
localChatRoom: LocalChatRoom,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit,
|
||||
) {
|
||||
val languages = selectedLanguages()
|
||||
|
||||
if (languages.isEmpty()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val createdAt = TimeUtils.now()
|
||||
|
||||
val sessions = runCatching {
|
||||
languages.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { wave ->
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
events = DialectScaffold.dialectsOf(
|
||||
languages = wave,
|
||||
createdAt = createdAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a wave of dialects for signing", error)
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
// The first, because it is the one that opened; any others are in the
|
||||
// room's proposal list beside it.
|
||||
val session = sessions.firstOrNull()
|
||||
|
||||
if (session != null) selected.clear()
|
||||
|
||||
// On the main thread because the callback navigates, and navigating
|
||||
// off Dispatchers.IO crashes. The handoff belongs here rather than at
|
||||
// the call site.
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
if (session != null) onSuccess.invoke(session.id) else onFailure.invoke()
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AddDialectsFromCatalogueViewModel"
|
||||
|
||||
|
||||
Reference in new issue
Block a user