From 3912631b2e502321a6ff4b1f732dcbbd251e1f1a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:36:42 +0200 Subject: [PATCH] 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 --- .../composeResources/values/strings.xml | 8 + .../ui/composable/ChatRoomDetailScreen.kt | 67 ++-- .../ui/composable/ProjectDetailScreen.kt | 319 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 23 ++ .../navigation/routes/ProjectDetailRoute.kt | 11 + .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/model/ProjectDetailViewModel.kt | 94 ++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 4 + .../ui/view/state/ProjectDetailUIState.kt | 41 +++ 9 files changed, 542 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..d58d615e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -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." --> + Add project Mantra Add a dialect the group can translate into Add artifact to library @@ -58,6 +59,13 @@ Create project Create the admin room Agree the group's signing key + Created by + No source versions in this project yet. + No translations in this project yet. + This group + Owners + Project detail + Source versions 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. The group has agreed what the admin room will sign with. The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 0689fb34..1e489436 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -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)) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt new file mode 100644 index 00000000..7cef5aa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -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, + 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 = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 92f022d0..99c08773 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + + 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 { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt new file mode 100644 index 00000000..ac483695 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3bfb3db2..260d5da1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt new file mode 100644 index 00000000..616a2cf3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -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): Map = + 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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 80f08814..e8fbf0f3 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -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 = emptyList(), val dialects: List = emptyList(), + + /** What the room's library is being used for. */ + val projects: List = emptyList(), /** * What the group has said this room signs with, or null while it has * said nothing. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt new file mode 100644 index 00000000..bb21e6c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -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 = emptyList(), + val translations: List = 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 = 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 +}