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:
Kgothatso Ngako
2026-10-02 19:36:42 +02:00
parent 9bd4216867
commit 3912631b2e
9 changed files with 542 additions and 26 deletions

View File

@@ -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>

View File

@@ -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))
}
}

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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>()

View File

@@ -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()

View File

@@ -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),

View File

@@ -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,
)
}
}
}
}

View File

@@ -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.

View File

@@ -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
}