feat(projects): a room lists its projects, and one can be opened
Phase 7a of docs/project-feature.md -- the read path. The write path and the
screen for adding to an existing project follow.
The room's project section was already there as a stub: a heading, a permanent
"No projects exists in this group", and a button to
`ImplementationPendingRoute("Add new project")`, under a `// TODO: Add
projects...`. That is what this replaces, so the strings it was already using
are reused rather than added beside near-identical ones -- which is also how
the duplicate `projects` entry I first wrote got caught.
`ProjectDetailScreen` shows the contents and then who the project belongs to.
The second half is the point of the screen as much as the first: the creator
and the owners are claims the group put its signature to, nobody else checks
them, and the signed event is the only place they exist. So they are read here
after the fact, resolved to profiles in the view model -- a composable cannot
await a database, and these are a handful of keys read together.
An owner that is the room's own key reads "This group" rather than printing the
room id back at a member standing inside it, and sorts first, so the ordinary
single-owner case is one unremarkable line and an outside owner is the one that
stands out.
M3 budgets met: spacing off the scale, strings in the catalogue in sentence
case, `NavigateBackButton` on a pushed screen, `readableContent()` on the
content root, all four states through `ScreenStateTransition`, every icon with
a decided description.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
an em dash or a comma. The compose resources plugin reports that only as
|
||||
"XML file ... is not valid. Check the file content."
|
||||
-->
|
||||
<string name="add_project">Add project</string>
|
||||
<string name="app_name">Mantra</string>
|
||||
<string name="add_a_dialect_the_group_can_translate_into">Add a dialect the group can translate into</string>
|
||||
<string name="add_artifact_to_library">Add artifact to library</string>
|
||||
@@ -58,6 +59,13 @@
|
||||
<string name="create_project">Create project</string>
|
||||
<string name="create_the_admin_room">Create the admin room</string>
|
||||
<string name="agree_the_groups_signing_key">Agree the group's signing key</string>
|
||||
<string name="created_by">Created by</string>
|
||||
<string name="no_source_versions_in_this_project">No source versions in this project yet.</string>
|
||||
<string name="no_translations_in_this_project">No translations in this project yet.</string>
|
||||
<string name="owned_by_this_group">This group</string>
|
||||
<string name="owners">Owners</string>
|
||||
<string name="project_detail">Project detail</string>
|
||||
<string name="source_versions">Source versions</string>
|
||||
<string name="the_group_is_agreeing_what_the_admin_room">The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat.</string>
|
||||
<string name="the_group_has_agreed_what_the_admin_room">The group has agreed what the admin room will sign with.</string>
|
||||
<string name="the_group_could_not_agree_what_the_admin">The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
|
||||
|
||||
@@ -85,6 +85,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.AddDialectRoute
|
||||
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
|
||||
import mantra.composeapp.generated.resources.run_by_members
|
||||
import mantra.composeapp.generated.resources.parent_group
|
||||
@@ -468,42 +469,56 @@ fun ChatRoomDetailScreen(
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// TODO: Add projects...
|
||||
|
||||
item {
|
||||
// Artifacts
|
||||
// Projects -- what the library above is for
|
||||
Text(
|
||||
text = stringResource(Res.string.projects),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(stringResource(Res.string.no_projects_exists_in_this_group))
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Navigate to the invite user screen...
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute(
|
||||
"Add new project"
|
||||
if (chatRoomDetailUIState.projects.isEmpty()) {
|
||||
item {
|
||||
Text(stringResource(Res.string.no_projects_exists_in_this_group))
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = chatRoomDetailUIState.projects,
|
||||
key = { project -> project.id }
|
||||
) { project ->
|
||||
Card(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
ProjectDetailRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
projectId = project.id,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Schema,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = project.name)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = project.description)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
|
||||
Icon(
|
||||
Icons.Default.Schema,
|
||||
contentDescription = "create new projec"
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(MaterialTheme.spacing.space125)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.create_project))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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.height
|
||||
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.Article
|
||||
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.created_by
|
||||
import mantra.composeapp.generated.resources.details
|
||||
import mantra.composeapp.generated.resources.no_source_versions_in_this_project
|
||||
import mantra.composeapp.generated.resources.no_translations_in_this_project
|
||||
import mantra.composeapp.generated.resources.owned_by_this_group
|
||||
import mantra.composeapp.generated.resources.owners
|
||||
import mantra.composeapp.generated.resources.project_detail
|
||||
import mantra.composeapp.generated.resources.source_versions
|
||||
import mantra.composeapp.generated.resources.translations
|
||||
import mantra.composeapp.generated.resources.version
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.extensions.shortened
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
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.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.ProjectDetailViewModel
|
||||
import press.mantra.compose.ui.view.state.ProjectDetailUIState
|
||||
|
||||
/**
|
||||
* What a body of work is, and who it belongs to.
|
||||
*
|
||||
* The two people rows are not decoration. A project's creator and owners are
|
||||
* claims the group put its signature to -- nobody else checks them, and the
|
||||
* event is the only place they exist -- so this screen is where a member reads
|
||||
* what was agreed after the fact.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProjectDetailScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
projectId: String,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
nostrRepository: NostrRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val projectDetailViewModel: ProjectDetailViewModel = viewModel(
|
||||
factory = ProjectDetailViewModel.factory(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
projectId = projectId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialProjectDetailUIState = initialProjectDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
nostrRepository = nostrRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
ScreenStateTransition(projectDetailViewModel.projectDetailUIState) { uiState ->
|
||||
when (val projectDetailUIState = uiState) {
|
||||
is ProjectDetailUIState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
Text(text = projectDetailUIState.message)
|
||||
}
|
||||
}
|
||||
|
||||
is ProjectDetailUIState.Loaded -> {
|
||||
val project = projectDetailUIState.project
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(project.name) },
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) },
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
if (project.description.isNotBlank()) {
|
||||
item { Text(project.description) }
|
||||
item { HorizontalDivider() }
|
||||
}
|
||||
|
||||
// Source versions
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.source_versions),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
if (projectDetailUIState.sourceVersions.isEmpty()) {
|
||||
item {
|
||||
Text(stringResource(Res.string.no_source_versions_in_this_project))
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = projectDetailUIState.sourceVersions,
|
||||
key = { version -> version.id }
|
||||
) { version ->
|
||||
Card {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Article,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
overlineContent = {
|
||||
Text(stringResource(Res.string.version))
|
||||
},
|
||||
headlineContent = { Text(version.versionLabel) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Translations
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.translations),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
if (projectDetailUIState.translations.isEmpty()) {
|
||||
item {
|
||||
Text(stringResource(Res.string.no_translations_in_this_project))
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = projectDetailUIState.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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Who it belongs to
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.details),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
item {
|
||||
val thisGroup = stringResource(Res.string.owned_by_this_group)
|
||||
|
||||
Card {
|
||||
DetailRow(
|
||||
label = stringResource(Res.string.created_by),
|
||||
value = describe(
|
||||
publicKey = project.creatorPublicKey,
|
||||
people = projectDetailUIState.people,
|
||||
chatRoomId = chatRoomId,
|
||||
thisGroup = thisGroup,
|
||||
)
|
||||
)
|
||||
|
||||
// The room first, so the ordinary single-owner
|
||||
// case is one unremarkable line and an outside
|
||||
// owner is the one that stands out.
|
||||
project.ownerPublicKeySet()
|
||||
.sortedBy { if (it == chatRoomId) 0 else 1 }
|
||||
.forEach { owner ->
|
||||
DetailRow(
|
||||
label = stringResource(Res.string.owners),
|
||||
value = describe(
|
||||
publicKey = owner,
|
||||
people = projectDetailUIState.people,
|
||||
chatRoomId = chatRoomId,
|
||||
thisGroup = thisGroup,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProjectDetailUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(Res.string.project_detail),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialProjectDetailUIState == ProjectDetailUIState.Loading) {
|
||||
projectDetailViewModel.initiateProjectDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key as a person, as this group, or as a short hash -- in that order.
|
||||
*
|
||||
* The room's own key reads as the group rather than as its id: a member
|
||||
* standing inside the room does not need it spelled back at them, and "this
|
||||
* group" is what the owner actually is.
|
||||
*/
|
||||
private fun describe(
|
||||
publicKey: HexKey,
|
||||
people: Map<HexKey, Profile>,
|
||||
chatRoomId: String,
|
||||
thisGroup: String,
|
||||
): String = when {
|
||||
publicKey == chatRoomId -> thisGroup
|
||||
else -> people[publicKey]?.humanReadableNameOrPubkey() ?: publicKey.shortened()
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun ProjectDetailScreenPreview() {
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ProjectDetailScreen(
|
||||
activeUserPublicKey = "a".repeat(64),
|
||||
projectId = "p".repeat(64),
|
||||
chatRoomId = "b".repeat(64),
|
||||
relayHint = null,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,11 +142,13 @@ import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
|
||||
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.TranslateChunkRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
|
||||
import press.mantra.compose.ui.composable.ArtifactDetailScreen
|
||||
import press.mantra.compose.ui.composable.ProjectDetailScreen
|
||||
import press.mantra.compose.ui.composable.AddChapterScreen
|
||||
import press.mantra.compose.ui.composable.AddDialectScreen
|
||||
import press.mantra.compose.ui.composable.FrostSigningScreen
|
||||
@@ -1219,6 +1221,27 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProjectDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProjectDetailRoute>()
|
||||
|
||||
ProjectDetailScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
projectId = route.projectId,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ChapterDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ChapterDetailRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProjectDetailRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val projectId: String,
|
||||
val chatRoomId: String,
|
||||
val relayHint: String?
|
||||
): Route()
|
||||
@@ -78,6 +78,7 @@ class ChatRoomDetailViewModel(
|
||||
localChatRoom = localChatRoom,
|
||||
artifacts = mantraRepository.getArtifacts(chatRoomId),
|
||||
dialects = mantraRepository.getDialects(chatRoomId),
|
||||
projects = mantraRepository.getProjects(chatRoomId),
|
||||
groupKeyState = chatRepository.groupKeyState(chatRoomId),
|
||||
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
|
||||
subgroups = chatRepository.subgroupsOf(chatRoomId),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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.database.model.Profile
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.view.state.ProjectDetailUIState
|
||||
|
||||
class ProjectDetailViewModel(
|
||||
val projectId: String,
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialProjectDetailUIState: ProjectDetailUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val nostrRepository: NostrRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
fun initiateProjectDetail() {
|
||||
logger.d("initiateProjectDetail: $projectId")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val project = mantraRepository.getProject(projectId)
|
||||
|
||||
projectDetailUIState = if (project == null) {
|
||||
ProjectDetailUIState.Error("Couldn't find the project")
|
||||
} else {
|
||||
ProjectDetailUIState.Loaded(
|
||||
project = project,
|
||||
sourceVersions = mantraRepository.getProjectArtifactVersions(projectId),
|
||||
translations = mantraRepository.getProjectTranslationArtifactVersions(projectId),
|
||||
// The creator and the owners, resolved once here. A screen
|
||||
// cannot await a database per row, and these are a handful
|
||||
// of keys read together.
|
||||
people = peopleBehind(
|
||||
project.ownerPublicKeySet() + project.creatorPublicKey
|
||||
),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun peopleBehind(publicKeys: Set<HexKey>): Map<HexKey, Profile> =
|
||||
publicKeys.mapNotNull { publicKey ->
|
||||
nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it }
|
||||
}.toMap()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ProjectDetailViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
projectId: String,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
nostrRepository: NostrRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
ProjectDetailViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
projectId = projectId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialProjectDetailUIState = initialProjectDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
nostrRepository = nostrRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.MantraProject
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface ChatRoomDetailUIState {
|
||||
@@ -13,6 +14,9 @@ sealed interface ChatRoomDetailUIState {
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val artifacts: List<MantraArtifact> = emptyList(),
|
||||
val dialects: List<MantraDialect> = emptyList(),
|
||||
|
||||
/** What the room's library is being used for. */
|
||||
val projects: List<MantraProject> = emptyList(),
|
||||
/**
|
||||
* What the group has said this room signs with, or null while it has
|
||||
* said nothing.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraProject
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.Profile
|
||||
|
||||
sealed interface ProjectDetailUIState {
|
||||
data class Loaded(
|
||||
val project: MantraProject,
|
||||
val sourceVersions: List<MantraArtifactVersion> = emptyList(),
|
||||
val translations: List<MantraTranslationArtifactVersion> = emptyList(),
|
||||
|
||||
/**
|
||||
* The profiles behind the creator and the owners, where this device has
|
||||
* them.
|
||||
*
|
||||
* Resolved in the view model rather than looked up as each row draws:
|
||||
* the screen shows a handful of keys and a composable cannot await a
|
||||
* database. A key with no profile here is shown shortened, which is what
|
||||
* the rest of the app does with a stranger.
|
||||
*/
|
||||
val people: Map<HexKey, Profile> = emptyMap(),
|
||||
|
||||
/**
|
||||
* Whether this device holds a share of the group's key.
|
||||
*
|
||||
* Adding to a project is a proposal, and a device that cannot sign
|
||||
* cannot open one -- so the buttons say so rather than failing when
|
||||
* pressed.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): ProjectDetailUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): ProjectDetailUIState
|
||||
|
||||
data object Loading: ProjectDetailUIState
|
||||
}
|
||||
Reference in New Issue
Block a user