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