feat(dialects): a dialect is a thing you can open
Artifacts, translations and projects each have a route, a state, a view model and a screen. A dialect had a `ListItem` inside `ChatRoomDetailScreen`'s `LazyColumn` with no `onClick`, which made it the one entity in a room a member could look at and not open. This is that screen, and the card finally gets the `onClick` it never had. The two lists are the reason it exists. A dialect is a name, a language code and a country; those three sit at the bottom under the details the way they do everywhere else, and what a member opens a dialect to find out is what the group has written in it. `MantraArtifact.dialectId` and `MantraTranslationArtifactVersion.dialectId` are the only two columns in the app that point at a dialect, and neither had a query. Both new queries are scoped by the dialect alone, with no `chatRoomId` beside it. `dialectId` is a foreign key into this room's own rows, so the key already confines the read to one room and a second filter would be a second spelling of a constraint that is already enforced. That is the one-room scope decision made load-bearing rather than restated; when a dialect can be named across rooms -- Phase 9 of docs/project-feature.md -- these two queries are among the places that have to learn about it, which is why the reasoning is on them rather than here. `Loaded` also carries `canAddSubgroup`, which Phase 3 needs and which is the same two conditions `ChatRoomDetailUIState` already states: a Marmot room, and a share of its key. It reads the room off the *dialect's* `chatRoomId` rather than the route's, so the parent of a subgroup made here is the room the dialect actually lives in even if a route is ever built by hand. A missing dialect is `Error` and not an empty `Loaded`, because "this room has written nothing in Sesotho" and "there is no such dialect here" would otherwise look identical and only one of them is worth a retry button. The test that earns its place is the two-dialects-in-one-room case: if either query ever drifted to scoping by room, every single-dialect test would still pass and that one would not. Phase 2 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
153e92f558
commit
0727a04bae
12 files changed
+781
-2
No files matched your search
@@ -89,6 +89,7 @@
|
||||
<string name="currently_no_messages_have_been_shared">Currently no messages have been shared.\nBreak the ice.</string>
|
||||
<string name="delete_group">Delete group</string>
|
||||
<string name="details">Details</string>
|
||||
<string name="dialect_detail">Dialect detail</string>
|
||||
<string name="dialect_name">Dialect name</string>
|
||||
<string name="dialects">Dialects</string>
|
||||
<string name="direct_message_detail">Direct message detail</string>
|
||||
@@ -183,6 +184,8 @@
|
||||
<string name="no_one_selected_yet">No one selected yet</string>
|
||||
<string name="no_one_to_add_yet">No one to add yet.</string>
|
||||
<string name="no_projects_exists_in_this_group">No projects exists in this group.</string>
|
||||
<string name="no_artifacts_are_written_in_this_dialect">No artifacts are written in this dialect yet.</string>
|
||||
<string name="no_translations_are_into_this_dialect">Nothing has been translated into this dialect yet.</string>
|
||||
<string name="no_translations_yet">No translations yet.</string>
|
||||
<string name="no_versions">No versions.</string>
|
||||
<string name="not_now">Not now</string>
|
||||
|
||||
+13
@@ -15,4 +15,17 @@ interface MantraArtifactDao {
|
||||
|
||||
@Query("SELECT * FROM MantraArtifact WHERE id = :id")
|
||||
suspend fun getArtifactById(id: String): MantraArtifact?
|
||||
|
||||
/**
|
||||
* The artifacts written in [dialectId], newest first.
|
||||
*
|
||||
* Scoped by the dialect alone and not also by a room. `dialectId` is a
|
||||
* foreign key into this room's own rows, so it cannot reach another room's
|
||||
* artifacts and a second `chatRoomId` filter would be a second spelling of a
|
||||
* constraint the key already enforces. The day a dialect can be named across
|
||||
* rooms -- see Phase 9 of docs/project-feature.md -- this is one of the
|
||||
* places that has to learn about it.
|
||||
*/
|
||||
@Query("SELECT * FROM MantraArtifact WHERE dialectId = :dialectId ORDER BY createdAt DESC")
|
||||
suspend fun getArtifactsByDialectId(dialectId: String): List<MantraArtifact>
|
||||
}
|
||||
+16
@@ -41,4 +41,20 @@ interface MantraTranslationArtifactVersionDao {
|
||||
|
||||
@Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id")
|
||||
suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion?
|
||||
|
||||
/**
|
||||
* The translations into [dialectId], newest first.
|
||||
*
|
||||
* Scoped by the dialect alone, for the reason
|
||||
* `MantraArtifactDao.getArtifactsByDialectId` gives: the foreign key already
|
||||
* confines it to one room.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM MantraTranslationArtifactVersion
|
||||
WHERE dialectId = :dialectId
|
||||
ORDER BY createdAt DESC
|
||||
"""
|
||||
)
|
||||
suspend fun getTranslationsByDialectId(dialectId: String): List<MantraTranslationArtifactVersion>
|
||||
}
|
||||
+8
@@ -104,6 +104,14 @@ class DatabaseMantraRepository(
|
||||
override suspend fun getDialect(id: String): MantraDialect? =
|
||||
database.mantraDialectDao().getDialectById(id)
|
||||
|
||||
override suspend fun getArtifactsForDialect(dialectId: String): List<MantraArtifact> =
|
||||
database.mantraArtifactDao().getArtifactsByDialectId(dialectId)
|
||||
|
||||
override suspend fun getTranslationsForDialect(
|
||||
dialectId: String
|
||||
): List<MantraTranslationArtifactVersion> =
|
||||
database.mantraTranslationArtifactVersionDao().getTranslationsByDialectId(dialectId)
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
|
||||
@@ -57,6 +57,12 @@ interface MantraRepository {
|
||||
|
||||
suspend fun getDialect(id: String): MantraDialect?
|
||||
|
||||
/** The artifacts written in a dialect, newest first. */
|
||||
suspend fun getArtifactsForDialect(dialectId: String): List<MantraArtifact>
|
||||
|
||||
/** The translations into a dialect, newest first. */
|
||||
suspend fun getTranslationsForDialect(dialectId: String): List<MantraTranslationArtifactVersion>
|
||||
|
||||
suspend fun getProjects(chatRoomId: String): List<MantraProject>
|
||||
|
||||
suspend fun getProject(id: String): MantraProject?
|
||||
@@ -128,6 +134,14 @@ interface MantraRepository {
|
||||
|
||||
override suspend fun getDialect(id: String): MantraDialect? = null
|
||||
|
||||
override suspend fun getArtifactsForDialect(
|
||||
dialectId: String
|
||||
): List<MantraArtifact> = emptyList()
|
||||
|
||||
override suspend fun getTranslationsForDialect(
|
||||
dialectId: String
|
||||
): List<MantraTranslationArtifactVersion> = emptyList()
|
||||
|
||||
override suspend fun getProjects(chatRoomId: String): List<MantraProject> = emptyList()
|
||||
|
||||
override suspend fun getProject(id: String): MantraProject? = null
|
||||
|
||||
+20
-2
@@ -105,6 +105,7 @@ import mantra.composeapp.generated.resources.subgroups
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
@@ -566,12 +567,29 @@ fun ChatRoomDetailScreen(
|
||||
items = chatRoomDetailUIState.dialects,
|
||||
key = { dialect -> dialect.id }
|
||||
) { dialect ->
|
||||
Card {
|
||||
Card(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
DialectDetailRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
dialectId = dialect.id,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = "Dialect"
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.country
|
||||
import mantra.composeapp.generated.resources.details
|
||||
import mantra.composeapp.generated.resources.dialect_detail
|
||||
import mantra.composeapp.generated.resources.language
|
||||
import mantra.composeapp.generated.resources.library
|
||||
import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect
|
||||
import mantra.composeapp.generated.resources.no_translations_are_into_this_dialect
|
||||
import mantra.composeapp.generated.resources.translations
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
|
||||
import press.mantra.compose.ui.composable.widgets.Decorative
|
||||
import press.mantra.compose.ui.composable.widgets.DetailRow
|
||||
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.DialectDetailViewModel
|
||||
import press.mantra.compose.ui.view.state.DialectDetailUIState
|
||||
|
||||
/**
|
||||
* A dialect, and what the group has written in it.
|
||||
*
|
||||
* The two lists are the screen. A dialect is a name, a language code and a
|
||||
* country, and those three are at the bottom under the details the way they are
|
||||
* everywhere else; what a member opens a dialect for is the artifacts written in
|
||||
* it and the translations into it, which are the only two things in this app that
|
||||
* point at a dialect at all.
|
||||
*
|
||||
* Until this screen the dialect card in `ChatRoomDetailScreen` had no `onClick`,
|
||||
* which made a dialect the one entity in a room a member could not open.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DialectDetailScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val dialectDetailViewModel: DialectDetailViewModel = viewModel(
|
||||
factory = DialectDetailViewModel.factory(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
dialectId = dialectId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialDialectDetailUIState = initialDialectDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
)
|
||||
)
|
||||
|
||||
ScreenStateTransition(dialectDetailViewModel.dialectDetailUIState) { uiState ->
|
||||
when (val dialectDetailUIState = uiState) {
|
||||
is DialectDetailUIState.Error -> ErrorState(
|
||||
message = dialectDetailUIState.message,
|
||||
// Reloading is worth offering even though the usual cause is a
|
||||
// dialect this device does not hold: a room's rows arrive on the
|
||||
// group's schedule, so the second attempt can genuinely find what
|
||||
// the first could not.
|
||||
onRetry = { dialectDetailViewModel.initiateDialectDetail() }
|
||||
)
|
||||
|
||||
is DialectDetailUIState.Loaded -> {
|
||||
val dialect = dialectDetailUIState.dialect
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(dialect.name) },
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) },
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.padding(MaterialTheme.spacing.containerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.relatedGap
|
||||
)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.library),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (dialectDetailUIState.artifacts.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
message = stringResource(
|
||||
Res.string.no_artifacts_are_written_in_this_dialect
|
||||
),
|
||||
icon = Icons.Default.LibraryBooks,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = dialectDetailUIState.artifacts,
|
||||
key = { artifact -> artifact.id }
|
||||
) { artifact ->
|
||||
Card(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
ArtifactDetailRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
artifactId = artifact.id,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.LibraryBooks,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(artifact.name) },
|
||||
supportingContent = { Text(artifact.url) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.translations),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (dialectDetailUIState.translations.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
message = stringResource(
|
||||
Res.string.no_translations_are_into_this_dialect
|
||||
),
|
||||
icon = Icons.Default.Translate,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = dialectDetailUIState.translations,
|
||||
key = { translation -> translation.id }
|
||||
) { translation ->
|
||||
Card(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
TranslationArtifactVersionDetailRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
translationArtifactVersionId = translation.id,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(translation.name) },
|
||||
supportingContent = { Text(translation.visibility) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.details),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Card {
|
||||
// The pair that is actually the dialect's
|
||||
// identity, and the name that is not: nothing in
|
||||
// the schema makes a dialect unique, so two rooms
|
||||
// naming `st`-in-Lesotho differently hold the same
|
||||
// dialect under two names.
|
||||
DetailRow(
|
||||
label = stringResource(Res.string.language),
|
||||
value = dialect.language
|
||||
)
|
||||
DetailRow(
|
||||
label = stringResource(Res.string.country),
|
||||
value = dialect.country
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DialectDetailUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(MaterialTheme.spacing.containerPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.containerPadding
|
||||
)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(Res.string.dialect_detail),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialDialectDetailUIState == DialectDetailUIState.Loading) {
|
||||
dialectDetailViewModel.initiateDialectDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun DialectDetailScreenPreview() {
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
DialectDetailScreen(
|
||||
activeUserPublicKey = "",
|
||||
dialectId = "dialectId",
|
||||
chatRoomId = "chatRoomId",
|
||||
relayHint = null,
|
||||
initialDialectDetailUIState = DialectDetailUIState.Loaded(
|
||||
dialect = MantraDialect(
|
||||
id = "dialectId",
|
||||
publicKey = "author",
|
||||
name = "Sesotho",
|
||||
country = "Lesotho",
|
||||
language = "st",
|
||||
signature = "",
|
||||
chatRoomId = "chatRoomId",
|
||||
)
|
||||
),
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -151,6 +151,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
|
||||
@@ -165,6 +166,7 @@ import press.mantra.compose.ui.composable.FrostSigningScreen
|
||||
import press.mantra.compose.ui.composable.ProposalListScreen
|
||||
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
|
||||
import press.mantra.compose.ui.composable.ChapterDetailScreen
|
||||
import press.mantra.compose.ui.composable.DialectDetailScreen
|
||||
import press.mantra.compose.ui.composable.TranslateChunkScreen
|
||||
import press.mantra.compose.ui.composable.TranslationChapterScreen
|
||||
import press.mantra.compose.ui.composable.TranslationArtifactVersionDetailScreen
|
||||
@@ -1326,6 +1328,26 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<DialectDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DialectDetailRoute>()
|
||||
|
||||
DialectDetailScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
dialectId = route.dialectId,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProjectDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProjectDetailRoute>()
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A dialect, and what is written in it.
|
||||
*
|
||||
* [chatRoomId] rides along although the dialect row already names its room,
|
||||
* because every sibling detail route carries it and the screens pass it on to
|
||||
* the routes they open. Reading it off the row instead would make this the one
|
||||
* route that cannot be navigated to before its entity has loaded.
|
||||
*/
|
||||
@Serializable
|
||||
data class DialectDetailRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val dialectId: String,
|
||||
val chatRoomId: String,
|
||||
val relayHint: String? = null
|
||||
): Route()
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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.launch
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.DialectDetailUIState
|
||||
|
||||
/**
|
||||
* A dialect, what is written in it, and whether it could have a room of its own.
|
||||
*
|
||||
* The two lists are the reason this screen exists: a dialect is a name, a
|
||||
* language code and a country, and the thing a member opens one to find out is
|
||||
* what the group has written in it. Both are read by the dialect's id alone --
|
||||
* see `MantraArtifactDao.getArtifactsByDialectId` for why no room is named
|
||||
* beside it.
|
||||
*/
|
||||
class DialectDetailViewModel(
|
||||
val dialectId: String,
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialDialectDetailUIState: DialectDetailUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var dialectDetailUIState: DialectDetailUIState by mutableStateOf(initialDialectDetailUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
fun initiateDialectDetail() {
|
||||
logger.d("initiateDialectDetail: $dialectId")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val dialect = mantraRepository.getDialect(dialectId)
|
||||
|
||||
dialectDetailUIState = if (dialect == null) {
|
||||
// Error rather than an empty `Loaded`, because the two are
|
||||
// different answers: "this room has written nothing in Sesotho"
|
||||
// and "there is no such dialect here" would otherwise look
|
||||
// identical, and only one of them is worth retrying.
|
||||
DialectDetailUIState.Error("Couldn't find the dialect")
|
||||
} else {
|
||||
// The dialect's own room, not the route's. They are the same
|
||||
// room in every path that exists today, and reading it off the
|
||||
// row is what keeps that true if a route is ever constructed by
|
||||
// hand -- the parent of a subgroup made here must be the room
|
||||
// the dialect actually lives in.
|
||||
val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId)
|
||||
|
||||
DialectDetailUIState.Loaded(
|
||||
dialect = dialect,
|
||||
artifacts = mantraRepository.getArtifactsForDialect(dialectId),
|
||||
translations = mantraRepository.getTranslationsForDialect(dialectId),
|
||||
canAddSubgroup = room?.chatRoom?.mlsGroupState != null &&
|
||||
chatRepository.canSign(dialect.chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DialectDetailViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
dialectId: String,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
DialectDetailViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
dialectId = dialectId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialDialectDetailUIState = initialDialectDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
|
||||
sealed interface DialectDetailUIState {
|
||||
data class Loaded(
|
||||
val dialect: MantraDialect,
|
||||
|
||||
/**
|
||||
* The artifacts written in this dialect.
|
||||
*
|
||||
* This and [translations] are what the screen is for. A dialect on its
|
||||
* own is three strings -- a name, a language code and a country -- and a
|
||||
* screen showing only those has no reason to exist. These are the two
|
||||
* tables that carry a `dialectId`, and between them they answer the only
|
||||
* question a member opens a dialect to ask.
|
||||
*/
|
||||
val artifacts: List<MantraArtifact> = emptyList(),
|
||||
|
||||
/** The translations into this dialect. */
|
||||
val translations: List<MantraTranslationArtifactVersion> = emptyList(),
|
||||
|
||||
/**
|
||||
* Whether this device could open a subgroup for this dialect at all.
|
||||
*
|
||||
* Two conditions, both about capability rather than permission, and the
|
||||
* same two `ChatRoomDetailUIState.canAddSubgroup` states: the room has to
|
||||
* be a Marmot one, since a NIP-17 room has no admins to be a parent's,
|
||||
* and this device has to hold a share of the group's key, because
|
||||
* certifying a child is a signature by the parent and
|
||||
* `FrostSigningManager.proposeSigningBatch` throws for a device that
|
||||
* cannot make one.
|
||||
*
|
||||
* The rest of the refusals are the picker's, where they can name who is
|
||||
* missing and what to do about it.
|
||||
*/
|
||||
val canAddSubgroup: Boolean = false,
|
||||
): DialectDetailUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): DialectDetailUIState
|
||||
|
||||
data object Loading: DialectDetailUIState
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.repository.DatabaseChatRepository
|
||||
import press.mantra.compose.database.repository.DatabaseMantraRepository
|
||||
import press.mantra.compose.ui.view.state.DialectDetailUIState
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What a dialect's screen is looking at, and what it leaves out.
|
||||
*
|
||||
* The lists are read by the dialect's id alone -- no room is named beside it,
|
||||
* because `dialectId` is a foreign key into this room's own rows and a second
|
||||
* filter would restate a constraint the key already enforces. So the case worth
|
||||
* having is a room holding *two* dialects: if the query ever drifted to scoping
|
||||
* by room instead, every test with one dialect in it would still pass and this
|
||||
* one would not.
|
||||
*
|
||||
* The other half is that a missing dialect is an error rather than an empty
|
||||
* screen. "This room has written nothing in Sesotho" and "there is no such
|
||||
* dialect here" would otherwise look identical, and only one of them is worth
|
||||
* offering a retry for.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DialectDetailJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
private val room = ProjectRoom(db)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
db.close()
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(UnconfinedTestDispatcher())
|
||||
|
||||
private val otherDialectId = "9".repeat(64)
|
||||
private val unusedDialectId = "d".repeat(64)
|
||||
|
||||
private fun viewModel(dialectId: String = room.dialectId) = DialectDetailViewModel(
|
||||
dialectId = dialectId,
|
||||
chatRoomId = room.roomId,
|
||||
activeUserPublicKey = room.member,
|
||||
relayHint = null,
|
||||
initialDialectDetailUIState = DialectDetailUIState.Loading,
|
||||
mantraRepository = DatabaseMantraRepository(db, scope),
|
||||
chatRepository = DatabaseChatRepository(db, scope),
|
||||
)
|
||||
|
||||
private suspend fun loaded(dialectId: String = room.dialectId): DialectDetailUIState.Loaded {
|
||||
val viewModel = viewModel(dialectId)
|
||||
viewModel.initiateDialectDetail()
|
||||
return awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Loaded }
|
||||
}
|
||||
|
||||
/** A second dialect in the same room, with its own artifact and translation. */
|
||||
private suspend fun seedSecondDialect() {
|
||||
db.mantraDialectDao().upsert(
|
||||
MantraDialect(
|
||||
id = otherDialectId,
|
||||
publicKey = room.roomId,
|
||||
name = "Setswana",
|
||||
country = "BW",
|
||||
language = "tn",
|
||||
signature = room.signature,
|
||||
chatRoomId = room.roomId,
|
||||
)
|
||||
)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = "8".repeat(64),
|
||||
publicKey = room.roomId,
|
||||
name = "Another book",
|
||||
url = "https://example.invalid/another",
|
||||
visibility = "private",
|
||||
dialectId = otherDialectId,
|
||||
license = "cc",
|
||||
chatRoomId = room.roomId,
|
||||
signature = room.signature,
|
||||
)
|
||||
)
|
||||
db.mantraTranslationArtifactVersionDao().upsert(
|
||||
MantraTranslationArtifactVersion(
|
||||
id = "4".repeat(64),
|
||||
publicKey = room.roomId,
|
||||
artifactVersionId = room.versionId,
|
||||
dialectId = otherDialectId,
|
||||
name = "Setswana Mockingbird",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
signature = room.signature,
|
||||
chatRoomId = room.roomId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dialect loads with what is written in it`() = runBlocking {
|
||||
room.seed()
|
||||
|
||||
val state = loaded()
|
||||
|
||||
assertEquals("Sesotho", state.dialect.name)
|
||||
assertEquals("st", state.dialect.language)
|
||||
assertEquals(listOf(room.artifactId), state.artifacts.map { it.id })
|
||||
assertEquals(listOf(room.translationId), state.translations.map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* The case that fails if the queries ever scope by room instead of by
|
||||
* dialect. Both dialects are in one room, so a room-scoped read returns all
|
||||
* four rows and reads as a working screen.
|
||||
*/
|
||||
@Test
|
||||
fun `another dialect in the same room is left out`() = runBlocking {
|
||||
room.seed()
|
||||
seedSecondDialect()
|
||||
|
||||
val sesotho = loaded()
|
||||
|
||||
assertEquals(listOf(room.artifactId), sesotho.artifacts.map { it.id })
|
||||
assertEquals(listOf(room.translationId), sesotho.translations.map { it.id })
|
||||
|
||||
val setswana = loaded(otherDialectId)
|
||||
|
||||
assertEquals(listOf("8".repeat(64)), setswana.artifacts.map { it.id })
|
||||
assertEquals(listOf("4".repeat(64)), setswana.translations.map { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state, which is a normal state and not a failure: a room defines
|
||||
* a dialect before it has anything in it, and that is the whole point of
|
||||
* being able to define one.
|
||||
*/
|
||||
@Test
|
||||
fun `a dialect nothing is written in loads empty rather than failing`() = runBlocking {
|
||||
room.seed()
|
||||
db.mantraDialectDao().upsert(
|
||||
MantraDialect(
|
||||
id = unusedDialectId,
|
||||
publicKey = room.roomId,
|
||||
name = "isiZulu",
|
||||
country = "ZA",
|
||||
language = "zu",
|
||||
signature = room.signature,
|
||||
chatRoomId = room.roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val state = loaded(unusedDialectId)
|
||||
|
||||
assertEquals("isiZulu", state.dialect.name)
|
||||
assertTrue(state.artifacts.isEmpty())
|
||||
assertTrue(state.translations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dialect this device does not hold is an error, not an empty screen`() = runBlocking {
|
||||
room.seed()
|
||||
|
||||
val viewModel = viewModel(dialectId = "c".repeat(64))
|
||||
viewModel.initiateDialectDetail()
|
||||
|
||||
val state = awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Error }
|
||||
|
||||
assertEquals("Couldn't find the dialect", state.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture's room has no MLS state, so no subgroup can be made from it --
|
||||
* a NIP-17 room has no admins to be a parent's. The flag says so instead of
|
||||
* the button failing when pressed.
|
||||
*/
|
||||
@Test
|
||||
fun `a room with no group state cannot have a subgroup made for its dialect`() = runBlocking {
|
||||
room.seed()
|
||||
|
||||
assertFalse(loaded().canAddSubgroup)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user