feat(projects): a member can ask the group to sign a project into being
Phase 7b of docs/project-feature.md -- the write path. The room's "Create project" button now opens a form instead of `ImplementationPendingRoute`. Nothing is created here. What goes out is a proposal, and the project appears on every member's device at once -- authored by the room's own key -- when enough members sign. That is the difference from submitting one: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social; a signature is the group saying it, and it takes a quorum to say. One session over the project and its contents. The memberships cannot be built by this screen, because each carries the project's id and that is a hash over the group's key at the room's derivation path -- it does not exist until the proposal is made. So the project leads and the memberships are derived from it, the same shape `AddArtifactViewModel` uses for an artifact and its first version. The creator is not a field: it is whoever is filling the form in, the view model already has it, and a picker would only let somebody credit the wrong person for something the group is about to sign. The owners are a field, pre-filled with this group. Most projects never touch it and that is why it is pre-filled rather than hidden -- a group making something for somebody else needs the question asked, and a group that is not needs to see the answer it is giving. Two guards, both because the alternative is silent: an entry that is not 64 characters is refused on the way in, since `ContributorTag.parse` would drop it and the owner would vanish between the form and the signature; and the last owner cannot be removed, since `ProjectEvent.build` refuses an empty set and a button that throws is worse than one that is off. A project with no contents is allowed, unlike an artifact with no version. An artifact with no version is inert because nothing can attach to it; an empty project is a name waiting for work. M3 budgets met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,9 @@
|
||||
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_an_owner">Add an owner</string>
|
||||
<string name="add_project">Add project</string>
|
||||
<string name="an_owner_is_a_64_character_public_key">An owner is a 64-character public key.</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>
|
||||
@@ -60,12 +62,19 @@
|
||||
<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="describe_the_project">What this project is</string>
|
||||
<string name="eg_the_four_gospels_into_sesotho">e.g. The four gospels into Sesotho</string>
|
||||
<string name="name_of_project">Name of project</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="owner_public_key">Owner public key</string>
|
||||
<string name="owners">Owners</string>
|
||||
<string name="project_detail">Project detail</string>
|
||||
<string name="propose_project">Propose project</string>
|
||||
<string name="remove_owner">Remove owner</string>
|
||||
<string name="source_versions">Source versions</string>
|
||||
<string name="start_a_project_in_this_group">Ask the group to sign a project into being</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>
|
||||
@@ -220,6 +229,7 @@
|
||||
<string name="shared_key">Shared key</string>
|
||||
<string name="signing_key">Signing key</string>
|
||||
<string name="the_key_this_room_signs_as">The key this room signs as, and the ceremony it came from.</string>
|
||||
<string name="this_group_has_no_library_to_build_a_project_from">This group has no library yet, so a project would have nothing in it. Add an artifact first.</string>
|
||||
<string name="what_the_group_signed">What the group signed</string>
|
||||
<string name="this_room_signs_as">This room signs as</string>
|
||||
<string name="derivation_path">Derivation path</string>
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Article
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DriveFileRenameOutline
|
||||
import androidx.compose.material.icons.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.BottomAppBarDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.add_an_owner
|
||||
import mantra.composeapp.generated.resources.an_owner_is_a_64_character_public_key
|
||||
import mantra.composeapp.generated.resources.describe_the_project
|
||||
import mantra.composeapp.generated.resources.eg_the_four_gospels_into_sesotho
|
||||
import mantra.composeapp.generated.resources.name_of_project
|
||||
import mantra.composeapp.generated.resources.owned_by_this_group
|
||||
import mantra.composeapp.generated.resources.owner_public_key
|
||||
import mantra.composeapp.generated.resources.owners
|
||||
import mantra.composeapp.generated.resources.project_detail
|
||||
import mantra.composeapp.generated.resources.propose_project
|
||||
import mantra.composeapp.generated.resources.remove_owner
|
||||
import mantra.composeapp.generated.resources.source_versions
|
||||
import mantra.composeapp.generated.resources.start_a_project_in_this_group
|
||||
import mantra.composeapp.generated.resources.this_group_has_no_library_to_build_a_project_from
|
||||
import mantra.composeapp.generated.resources.translations
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.extensions.shortened
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
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.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.Decorative
|
||||
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.composable.widgets.buttons.ExtendedFab
|
||||
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.AddProjectViewModel
|
||||
import press.mantra.compose.ui.view.state.AddProjectUIState
|
||||
|
||||
/**
|
||||
* Proposing a project: a name, what it is, who owns it, and what is in it.
|
||||
*
|
||||
* The creator is not a field. It is whoever is filling the form in, the view
|
||||
* model already knows it, and a picker would only let somebody credit the wrong
|
||||
* person for work the group is about to put its signature to.
|
||||
*
|
||||
* The owners are a field, pre-filled with this group. Most projects never touch
|
||||
* it, and that is the point of pre-filling rather than hiding: a group making
|
||||
* something for somebody else needs the question asked, and a group that is not
|
||||
* needs to see the answer it is giving.
|
||||
*/
|
||||
@OptIn(
|
||||
ExperimentalMaterial3ExpressiveApi::class,
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalLayoutApi::class
|
||||
)
|
||||
@Composable
|
||||
fun AddProjectScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val addProjectViewModel: AddProjectViewModel = viewModel(
|
||||
factory = AddProjectViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddProjectUIState = initialAddProjectUIState,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
mantraRepository = mantraRepository,
|
||||
)
|
||||
)
|
||||
|
||||
ScreenStateTransition(addProjectViewModel.addProjectUIState) { uiState ->
|
||||
when (val addProjectUIState = uiState) {
|
||||
is AddProjectUIState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
Text(text = addProjectUIState.message)
|
||||
}
|
||||
}
|
||||
|
||||
is AddProjectUIState.Loaded -> {
|
||||
val nameFieldState = rememberTextFieldState()
|
||||
val descriptionFieldState = rememberTextFieldState()
|
||||
val ownerFieldState = rememberTextFieldState()
|
||||
|
||||
// Pre-filled with the room, which is the common answer and a real
|
||||
// value rather than an absence: "owned by us" and "owner unstated"
|
||||
// must not be the same event.
|
||||
val owners: SnapshotStateList<HexKey> = remember {
|
||||
mutableStateListOf(chatRoomId)
|
||||
}
|
||||
val selectedSourceVersions = remember { mutableStateListOf<HexKey>() }
|
||||
val selectedTranslations = remember { mutableStateListOf<HexKey>() }
|
||||
|
||||
var nameEntered by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(nameFieldState.text) {
|
||||
nameEntered = nameFieldState.text.isNotBlank()
|
||||
}
|
||||
|
||||
// A key that is not 64 hex characters is dropped by
|
||||
// ContributorTag.parse, so it would vanish between this form and
|
||||
// the signature. Caught on entry instead.
|
||||
val typedOwner = ownerFieldState.text.toString().trim()
|
||||
val canAddOwner = typedOwner.length == OWNER_KEY_LENGTH &&
|
||||
typedOwner !in owners
|
||||
|
||||
val canAddProject = nameEntered &&
|
||||
owners.isNotEmpty() &&
|
||||
addProjectUIState.canSign
|
||||
|
||||
// imePadding: this screen has text fields, and without it the
|
||||
// software keyboard covers whatever is being typed into.
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
modifier = Modifier.imePadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
addProjectUIState.localChatRoom.RenderChatRoomTitleText()
|
||||
},
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) },
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFab(
|
||||
label = stringResource(Res.string.propose_project),
|
||||
icon = {
|
||||
Icon(Icons.Default.Add, contentDescription = Decorative)
|
||||
},
|
||||
enabled = canAddProject,
|
||||
onClick = {
|
||||
addProjectViewModel.addProject(
|
||||
localChatRoom = addProjectUIState.localChatRoom,
|
||||
nameField = nameFieldState,
|
||||
descriptionField = descriptionFieldState,
|
||||
ownerPublicKeys = owners.toSet(),
|
||||
sourceVersionIds = selectedSourceVersions.toList(),
|
||||
translationIds = selectedTranslations.toList(),
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than to
|
||||
// the project. Nothing exists yet
|
||||
// -- it appears when enough members
|
||||
// sign -- so a detail screen for a
|
||||
// row that is not there would read
|
||||
// as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
onNavigateToRoute.invoke(
|
||||
ImplementationPendingRoute("Failed project")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.space125
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(Res.string.start_a_project_in_this_group))
|
||||
|
||||
if (!addProjectUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"project into being. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = nameFieldState,
|
||||
colors = transparentOutline(),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.DriveFileRenameOutline,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(Res.string.name_of_project)) },
|
||||
placeholder = {
|
||||
Text(stringResource(Res.string.eg_the_four_gospels_into_sesotho))
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = descriptionFieldState,
|
||||
colors = transparentOutline(),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Notes, contentDescription = Decorative)
|
||||
},
|
||||
label = { Text(stringResource(Res.string.describe_the_project)) },
|
||||
)
|
||||
|
||||
// --- owners ---
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(Res.string.owners),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
val thisGroup = stringResource(Res.string.owned_by_this_group)
|
||||
|
||||
owners.forEach { owner ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
if (owner == chatRoomId) thisGroup else owner.shortened()
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
// The last one cannot go: ProjectEvent.build
|
||||
// refuses an empty set, and a button that
|
||||
// throws is worse than one that is off.
|
||||
IconButton(
|
||||
enabled = owners.size > 1,
|
||||
onClick = { owners.remove(owner) }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(
|
||||
Res.string.remove_owner
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = ownerFieldState,
|
||||
colors = transparentOutline(),
|
||||
label = { Text(stringResource(Res.string.owner_public_key)) },
|
||||
isError = typedOwner.isNotEmpty() && !canAddOwner,
|
||||
supportingText = {
|
||||
if (typedOwner.isNotEmpty() && !canAddOwner) {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.an_owner_is_a_64_character_public_key
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
IconButton(
|
||||
enabled = canAddOwner,
|
||||
onClick = {
|
||||
owners.add(typedOwner)
|
||||
ownerFieldState.clearText()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = stringResource(Res.string.add_an_owner)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- what is in it ---
|
||||
|
||||
if (addProjectUIState.sourceVersions.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.this_group_has_no_library_to_build_a_project_from
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(Res.string.source_versions),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.itemGap
|
||||
)
|
||||
) {
|
||||
addProjectUIState.sourceVersions.forEach { version ->
|
||||
FilterChip(
|
||||
selected = version.id in selectedSourceVersions,
|
||||
onClick = {
|
||||
if (version.id in selectedSourceVersions) {
|
||||
selectedSourceVersions.remove(version.id)
|
||||
} else {
|
||||
selectedSourceVersions.add(version.id)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Article,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
label = { Text(version.versionLabel) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addProjectUIState.translations.isNotEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(Res.string.translations),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
MaterialTheme.spacing.itemGap
|
||||
)
|
||||
) {
|
||||
addProjectUIState.translations.forEach { translation ->
|
||||
FilterChip(
|
||||
selected = translation.id in selectedTranslations,
|
||||
onClick = {
|
||||
if (translation.id in selectedTranslations) {
|
||||
selectedTranslations.remove(translation.id)
|
||||
} else {
|
||||
selectedTranslations.add(translation.id)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Translate,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
},
|
||||
label = { Text(translation.name) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddProjectUIState.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 (initialAddProjectUIState == AddProjectUIState.Loading) {
|
||||
addProjectViewModel.initiateAddProject()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What `ContributorTag.parse` demands of an owner's value. */
|
||||
private const val OWNER_KEY_LENGTH = 64
|
||||
|
||||
/** The borderless field the rest of this family's forms use. */
|
||||
@Composable
|
||||
private fun transparentOutline() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
)
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun AddProjectScreenPreview() {
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AddProjectScreen(
|
||||
activeUserPublicKey = "a".repeat(64),
|
||||
chatRoomId = "b".repeat(64),
|
||||
relayHint = null,
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
|
||||
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute
|
||||
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
|
||||
@@ -522,6 +523,31 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
AddProjectRoute(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Schema,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(MaterialTheme.spacing.space125)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.create_project))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.repository.DatabaseMantraRepository
|
||||
import press.mantra.compose.ui.composable.AddArtifactScreen
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
@@ -147,6 +148,7 @@ 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.AddProjectScreen
|
||||
import press.mantra.compose.ui.composable.ArtifactDetailScreen
|
||||
import press.mantra.compose.ui.composable.ProjectDetailScreen
|
||||
import press.mantra.compose.ui.composable.AddChapterScreen
|
||||
@@ -1092,6 +1094,38 @@ fun MantraNavHost(
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<AddProjectRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddProjectRoute>()
|
||||
|
||||
AddProjectScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this add screen so back returns to the group rather
|
||||
// than to a form whose proposal has already gone out.
|
||||
navController.navigate(
|
||||
route = signingRoute
|
||||
) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
)
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<AddArtifactRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddArtifactRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AddProjectRoute(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: String,
|
||||
val relayHint: String?
|
||||
): Route()
|
||||
@@ -0,0 +1,206 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.runtime.MutableState
|
||||
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 com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
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.AddProjectUIState
|
||||
|
||||
class AddProjectViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialAddProjectUIState: AddProjectUIState,
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val mantraRepository: MantraRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addProjectUIState: AddProjectUIState by mutableStateOf(initialAddProjectUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
fun initiateAddProject() {
|
||||
logger.d("initiateAddProject: $chatRoomId")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
addProjectUIState = if (localChatRoom == null) {
|
||||
AddProjectUIState.Error("Couldn't find the chat room")
|
||||
} else {
|
||||
AddProjectUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
sourceVersions = sourceVersionsOf(localChatRoom.chatRoom.id),
|
||||
translations = translationsOf(localChatRoom.chatRoom.id),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version in the room's library, walked down from its artifacts.
|
||||
*
|
||||
* There is no by-room query for versions -- a version hangs off an artifact,
|
||||
* which is the same reason the chronicle walks the tree rather than querying
|
||||
* per kind. A room's library is small and this runs once when the form
|
||||
* opens.
|
||||
*/
|
||||
private suspend fun sourceVersionsOf(chatRoomId: String) =
|
||||
mantraRepository.getArtifacts(chatRoomId)
|
||||
.flatMap { mantraRepository.getArtifactVersions(it.id) }
|
||||
|
||||
private suspend fun translationsOf(chatRoomId: String) =
|
||||
sourceVersionsOf(chatRoomId)
|
||||
.flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) }
|
||||
|
||||
/**
|
||||
* Asks the group to sign a project into being, with whatever it starts with.
|
||||
*
|
||||
* The project is not created here and does not exist yet, the way an
|
||||
* artifact does not: what goes out is a proposal, and the project appears on
|
||||
* every member's device at once -- authored by the room's own key -- when
|
||||
* enough members have signed.
|
||||
*
|
||||
* One session over the project and its first contents. The memberships
|
||||
* cannot be built by this screen because each carries the project's id,
|
||||
* which is a hash over the group's key at the room's derivation path: it
|
||||
* does not exist until the proposal is made. So the project leads and the
|
||||
* memberships are derived from it, the same shape an artifact and its first
|
||||
* version use.
|
||||
*
|
||||
* All-or-nothing, which is right. A project is a statement about which work
|
||||
* belongs together, and half of that statement signed is not a smaller
|
||||
* version of it.
|
||||
*
|
||||
* [creatorPublicKey] is this member and is not asked for. [ownerPublicKeys]
|
||||
* is asked for and defaults to the room, and must not be empty -- the event
|
||||
* builder refuses one, and the form keeps the last entry from being removed
|
||||
* rather than letting it get that far.
|
||||
*/
|
||||
fun addProject(
|
||||
localChatRoom: LocalChatRoom,
|
||||
nameField: TextFieldState,
|
||||
descriptionField: TextFieldState,
|
||||
ownerPublicKeys: Set<HexKey>,
|
||||
sourceVersionIds: List<HexKey>,
|
||||
translationIds: List<HexKey>,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val name = nameField.text.toString()
|
||||
val description = descriptionField.text.toString()
|
||||
|
||||
if (name.isBlank() || ownerPublicKeys.isEmpty()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val projectEventTemplate = ProjectEvent.build(
|
||||
name = name,
|
||||
description = description,
|
||||
creatorPublicKey = activeUserPublicKey,
|
||||
ownerPublicKeys = ownerPublicKeys,
|
||||
)
|
||||
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
lead = projectEventTemplate,
|
||||
dependents = { project ->
|
||||
buildList<EventTemplate<*>> {
|
||||
addAll(
|
||||
ProjectArtifactVersionEvent.membershipsOf(
|
||||
project = project,
|
||||
artifactVersionIds = sourceVersionIds,
|
||||
)
|
||||
)
|
||||
addAll(
|
||||
ProjectTranslationArtifactVersionEvent.membershipsOf(
|
||||
project = project,
|
||||
translationArtifactVersionIds = translationIds,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a project for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
if (session != null) {
|
||||
nameField.clearText()
|
||||
descriptionField.clearText()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AddProjectViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddProjectViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddProjectUIState = initialAddProjectUIState,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
mantraRepository = mantraRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
sealed interface AddProjectUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
|
||||
/** Everything in the room's library a project could be about. */
|
||||
val sourceVersions: List<MantraArtifactVersion> = emptyList(),
|
||||
|
||||
/** And everything being translated. */
|
||||
val translations: List<MantraTranslationArtifactVersion> = emptyList(),
|
||||
|
||||
/**
|
||||
* Whether this device holds a share of the group's key.
|
||||
*
|
||||
* A project is proposed, not created, so a device that cannot sign
|
||||
* cannot start one. Said on the form rather than discovered on the
|
||||
* button.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddProjectUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): AddProjectUIState
|
||||
|
||||
data object Loading: AddProjectUIState
|
||||
}
|
||||
Reference in New Issue
Block a user