feat: fade between a screen's states instead of cutting between them

Phase 7, the second half. Every screen in this app is a `when` over a UI state --
loading, error, empty, loaded -- and every one of those changes was an
unannounced cut: the spinner is there in one frame and the content is there in
the next, with nothing saying they are the same screen answering the same
question.

`ScreenStateTransition` is M3's fade-through, which is the transition for content
that replaces other content without being spatially related to it: the outgoing
state fades out, the incoming one fades in and grows the last 8% into place.
`SizeTransform(clip = false)`, so a tall loaded state does not stretch a short
spinner on its way in. Specs from the theme's `MotionScheme`, effects for the
fade and spatial for the scale.

**The content key is the state's class, not the state.** This is the half that is
easy to get wrong and impossible to see: keyed on the value, a screen re-runs the
whole fade every time its loaded data changes -- a message arriving, a list
growing by one -- so the screen flickers whenever anything happens, and every
screenshot of it looks perfect. Keyed on the class, the animation runs when the
state does and the data flows through untouched. There is a test for exactly
that, and it is the more useful of the two.

**Applied to 20 screens, and not to 15 others.** `AnimatedContent` is a layout
node, so it can only wrap a `when` that is a composable's whole body. Where the
`when` sits inside a `Column` whose branches use `Modifier.weight` -- the sign-in
and create-profile flows, the frost signing and proposal screens, the two feed
detail widgets, the four render helpers still on view models -- wrapping it would
take those branches out of `ColumnScope`. The rule is mechanical, the reason is
recorded once in `ScreenState.kt` rather than at each site, and the screens it
excludes are named here rather than silently skipped.

Reduced motion keeps the crossfade and drops the scale, which is the same
position the navigation transitions take: what WCAG 2.3.3 and M3 ask to remove is
movement, not the signal that something changed.

**Most of this diff is indentation** -- 3,699 lines of it against 157 lines of
substance, which is 21 screens gaining a wrapper and one helper being written.
`git diff -w` shows the second number.

**Verified by holding the clock still and looking at one frame**, which is the
only frame that can tell a crossfade from a cut: during a transition both states
are composed, and during a cut only ever one is.

639 jvm tests green; android and desktop compile. The audit's motion count goes
11 -> 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 08:16:05 +02:00
parent 61793e2779
commit 410ece2df9
23 changed files with 3991 additions and 3769 deletions

View File

@@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ButtonDefaults
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -91,296 +92,298 @@ fun ActiveProfileScreen(
),
)
when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) {
ActiveProfileUIState.Error -> {
ErrorState()
}
is ActiveProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.profile)
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
ScreenStateTransition(activeProfileViewModel.activeProfileUIState) { uiState ->
when (val activeProfileUIState = uiState) {
ActiveProfileUIState.Error -> {
ErrorState()
}
is ActiveProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.profile)
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
ProfileAvatar(
profile = activeProfileUIState.localNostrEvent.profile,
publicKey = activeUserPublicKey
)
Column {
activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey ->
Text(
text = humanReadableNameOrPubkey,
style = MaterialTheme.typography.titleLarge
)
}
activeProfileUIState.localNostrEvent.profile?.about?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space125)
)
Text(
text = activeUserPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125)
) {
Spacer(
modifier = Modifier.weight(1f)
)
TextButton(
},
navigationIcon = {
IconButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Edit profile")
)
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.edit_profile)
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
ProfileAvatar(
profile = activeProfileUIState.localNostrEvent.profile,
publicKey = activeUserPublicKey
)
Spacer(
modifier = Modifier.weight(1f)
)
Column {
activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey ->
Text(
text = humanReadableNameOrPubkey,
style = MaterialTheme.typography.titleLarge
)
}
activeProfileUIState.localNostrEvent.profile?.about?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
// The list this sits in is otherwise TextButtons --
// edit profile, key packages, change account, profile
// keys, network relays. Two of the seven were filled
// Buttons, which is M3's highest emphasis and is meant
// for one action per screen. Sharing is the useful one,
// so it keeps medium emphasis rather than maximum.
FilledTonalButton(
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space125)
)
Text(
text = activeUserPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125)
) {
Spacer(
modifier = Modifier.weight(1f)
)
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Edit profile")
)
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.edit_profile)
)
}
Spacer(
modifier = Modifier.weight(1f)
)
// The list this sits in is otherwise TextButtons --
// edit profile, key packages, change account, profile
// keys, network relays. Two of the seven were filled
// Buttons, which is M3's highest emphasis and is meant
// for one action per screen. Sharing is the useful one,
// so it keeps medium emphasis rather than maximum.
FilledTonalButton(
onClick = {
onNavigateToRoute.invoke(
ShareProfileRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = nostrEventId
)
)
},
) {
Icon(
Icons.Default.QrCode,
contentDescription = "QR code"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.share_profile)
)
}
Spacer(
modifier = Modifier.weight(1f)
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ShareProfileRoute(
KeyPackageManagementRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = nostrEventId
)
)
},
}
) {
Icon(
Icons.Default.QrCode,
contentDescription = "QR code"
Icons.Default.Bento,
contentDescription = "Key packages"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.share_profile)
stringResource(Res.string.key_package_management)
)
}
Spacer(
modifier = Modifier.weight(1f)
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
KeyPackageManagementRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = nostrEventId
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Change account")
)
}
) {
Icon(
Icons.Default.ImportExport,
contentDescription = "Change profile"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.change_account)
)
}
) {
Icon(
Icons.Default.Bento,
contentDescription = "Key packages"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.key_package_management)
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Change account")
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Profile keys")
)
}
) {
Icon(
Icons.Default.Key,
contentDescription = "Profile keys"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.profile_keys))
}
) {
Icon(
Icons.Default.ImportExport,
contentDescription = "Change profile"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.change_account)
)
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Network relays")
)
item {
TextButton(
onClick = {
}
) {
Icon(
Icons.Default.Hub,
contentDescription = "Network relays"
)
onNavigateToRoute.invoke(
ImplementationPendingRoute("Profile keys")
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.network_relays)
)
}
) {
Icon(
Icons.Default.Key,
contentDescription = "Profile keys"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.profile_keys))
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Network relays")
item {
// Signing out was the *other* filled Button -- the most
// prominent control on the screen given to its most
// destructive action. It now matches how leaving and
// deleting a group are already treated in
// ChatRoomDetailScreen: a TextButton in the error colour.
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign out")
)
}
) {
Icon(
Icons.Default.Logout,
contentDescription = "Logout"
)
}
) {
Icon(
Icons.Default.Hub,
contentDescription = "Network relays"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.network_relays)
)
}
}
item {
// Signing out was the *other* filled Button -- the most
// prominent control on the screen given to its most
// destructive action. It now matches how leaving and
// deleting a group are already treated in
// ChatRoomDetailScreen: a TextButton in the error colour.
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign out")
Text(
stringResource(Res.string.sign_out)
)
}
) {
Icon(
Icons.Default.Logout,
contentDescription = "Logout"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
stringResource(Res.string.sign_out)
)
}
}
}
}
}
}
ActiveProfileUIState.Loading -> {
LoadingDataIndicator()
ActiveProfileUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
activeProfileViewModel.loadNostrFeed()
LaunchedEffect(true) {
activeProfileViewModel.loadNostrFeed()
}
}
}
ActiveProfileUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
ActiveProfileUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
}
}
}
}

View File

@@ -86,6 +86,7 @@ import mantra.composeapp.generated.resources.url
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -114,280 +115,282 @@ fun AddArtifactScreen(
)
)
when (val addArtifactUIState = addArtifactViewModel.addArtifactUIState) {
is AddArtifactUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addArtifactUIState.message,
)
}
}
is AddArtifactUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val urlFieldState = rememberTextFieldState()
val versionLabelFieldState = rememberTextFieldState("1.0")
// The id of the group dialect this artifact is written in. Null until
// one is picked; dialects are defined from the group detail screen.
var selectedDialectId: String? by remember { mutableStateOf(null) }
// The two things the form cannot ask for again. A dialect has to
// already exist and nothing here can create one; a shared key has to
// have been ceremonied and this is not where that happens. Either
// missing is a dead end rather than something to propose and be told
// about afterwards, and the button says so.
val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = {
addArtifactUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
ScreenStateTransition(addArtifactViewModel.addArtifactUIState) { uiState ->
when (val addArtifactUIState = uiState) {
is AddArtifactUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canAddArtifact) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (canAddArtifact) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canAddArtifact) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!canAddArtifact) return@ExtendedFloatingActionButton
addArtifactViewModel.addArtifact(
localChatRoom = addArtifactUIState.localChatRoom,
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
dialectId = selectedDialectId,
onSuccess = { sessionId ->
// Onto the session rather than to the
// artifact. Nothing has been created yet
// -- the artifact appears when enough
// members sign -- so a detail screen for
// a row that does not exist would read as
// a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed artifact")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose artifact"
)
Text(stringResource(Res.string.propose_artifact))
}
}
Text(
text = addArtifactUIState.message,
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
// TODO: Check that we have direct message relays for this user...
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.add_artifact_to_the_group_library))
}
is AddArtifactUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val urlFieldState = rememberTextFieldState()
val versionLabelFieldState = rememberTextFieldState("1.0")
if (!addArtifactUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign an " +
"artifact into its library. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// The id of the group dialect this artifact is written in. Null until
// one is picked; dialects are defined from the group detail screen.
var selectedDialectId: String? by remember { mutableStateOf(null) }
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = "Name of the artifact"
)
},
label = {
Text(
text = stringResource(Res.string.name_of_artifact)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_to_kill_a_mocking_bird)
)
// The two things the form cannot ask for again. A dialect has to
// already exist and nothing here can create one; a shared key has to
// have been ceremonied and this is not where that happens. Either
// missing is a dead end rather than something to propose and be told
// about afterwards, and the button says so.
val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = {
addArtifactUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canAddArtifact) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (canAddArtifact) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canAddArtifact) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!canAddArtifact) return@ExtendedFloatingActionButton
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = urlFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Link,
contentDescription = "Link to the artifact"
)
},
label = {
Text(
text = stringResource(Res.string.url)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_https_harper_com_2_kill_bird)
)
},
)
addArtifactViewModel.addArtifact(
localChatRoom = addArtifactUIState.localChatRoom,
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
dialectId = selectedDialectId,
onSuccess = { sessionId ->
// Onto the session rather than to the
// artifact. Nothing has been created yet
// -- the artifact appears when enough
// members sign -- so a detail screen for
// a row that does not exist would read as
// a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed artifact")
)
}
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = versionLabelFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.LocalOffer,
contentDescription = "Verison label"
)
},
label = {
Text(
text = stringResource(Res.string.initial_version_label)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_first_edition)
)
},
)
Text(stringResource(Res.string.source_dialect))
if (addArtifactUIState.dialects.isEmpty()) {
Text(
text = stringResource(Res.string.no_dialects_have_been_defined_in_this_group),
style = MaterialTheme.typography.bodySmall
)
} else {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose artifact"
)
Text(stringResource(Res.string.propose_artifact))
}
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
// TODO: Check that we have direct message relays for this user...
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.add_artifact_to_the_group_library))
// TODO: Add Visibility
if (!addArtifactUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign an " +
"artifact into its library. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = "Name of the artifact"
)
},
label = {
Text(
text = stringResource(Res.string.name_of_artifact)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_to_kill_a_mocking_bird)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = urlFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Link,
contentDescription = "Link to the artifact"
)
},
label = {
Text(
text = stringResource(Res.string.url)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_https_harper_com_2_kill_bird)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = versionLabelFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.LocalOffer,
contentDescription = "Verison label"
)
},
label = {
Text(
text = stringResource(Res.string.initial_version_label)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_first_edition)
)
},
)
Text(stringResource(Res.string.source_dialect))
if (addArtifactUIState.dialects.isEmpty()) {
Text(
text = stringResource(Res.string.no_dialects_have_been_defined_in_this_group),
style = MaterialTheme.typography.bodySmall
)
} else {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
}
}
// TODO: Add Visibility
}
}
}
}
}
AddArtifactUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
AddArtifactUIState.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.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.add_chapter_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -96,206 +97,208 @@ fun AddChapterScreen(
)
)
when (val addChapterUIState = addChapterViewModel.addChapterUIState) {
is AddChapterUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addChapterUIState.message)
ScreenStateTransition(addChapterViewModel.addChapterUIState) { uiState ->
when (val addChapterUIState = uiState) {
is AddChapterUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addChapterUIState.message)
}
}
}
is AddChapterUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val originalTextFieldState = rememberTextFieldState()
is AddChapterUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val originalTextFieldState = rememberTextFieldState()
// Live counts / paragraph (chunk) preview from the markdown text.
val originalText = originalTextFieldState.text.toString()
val wordCount = Markdown.wordCount(originalText)
val characterCount = Markdown.characterCount(originalText)
val paragraphCount = Markdown.splitParagraphs(originalText).size
// Live counts / paragraph (chunk) preview from the markdown text.
val originalText = originalTextFieldState.text.toString()
val wordCount = Markdown.wordCount(originalText)
val characterCount = Markdown.characterCount(originalText)
val paragraphCount = Markdown.splitParagraphs(originalText).size
// A chapter hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addChapterUIState.artifactVersion
// A chapter hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addChapterUIState.artifactVersion
// The chapter and a chunk per paragraph are signed in one session,
// and a session signs a bounded number of events. Past that the
// chapter has to be split in two, which is worth saying while there
// is still a cursor in the text rather than after a failed propose.
val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER
// The chapter and a chunk per paragraph are signed in one session,
// and a session signs a bounded number of events. Past that the
// chapter has to be split in two, which is worth saying while there
// is still a cursor in the text rather than after a failed propose.
val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER
val canProposeChapter = artifactVersion != null &&
addChapterUIState.canSign &&
paragraphCount > 0 &&
!tooManyChunks
val canProposeChapter = artifactVersion != null &&
addChapterUIState.canSign &&
paragraphCount > 0 &&
!tooManyChunks
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeChapter) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeChapter) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeChapter) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || !canProposeChapter) {
return@ExtendedFloatingActionButton
}
addChapterViewModel.addChapter(
localChatRoom = addChapterUIState.localChatRoom,
artifactVersion = artifactVersion,
nameField = nameFieldState,
originalTextField = originalTextFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the chapter appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed chapter")
)
}
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose chapter"
)
Text(stringResource(Res.string.propose_chapter))
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeChapter) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeChapter) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeChapter) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || !canProposeChapter) {
return@ExtendedFloatingActionButton
}
addChapterViewModel.addChapter(
localChatRoom = addChapterUIState.localChatRoom,
artifactVersion = artifactVersion,
nameField = nameFieldState,
originalTextField = originalTextFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the chapter appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed chapter")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose chapter"
)
Text(stringResource(Res.string.propose_chapter))
}
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!addChapterUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"chapter into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = stringResource(Res.string.this_artifact_has_no_version_for_a_chapter),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (tooManyChunks) {
Text(
text = "$paragraphCount chunks is more than the group can sign in " +
"one go. Split the chapter so that no part of it is over " +
"${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = nameFieldState,
leadingIcon = {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = "Name of the chapter"
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!addChapterUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"chapter into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = stringResource(Res.string.this_artifact_has_no_version_for_a_chapter),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (tooManyChunks) {
Text(
text = "$paragraphCount chunks is more than the group can sign in " +
"one go. Split the chapter so that no part of it is over " +
"${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
},
label = { Text(stringResource(Res.string.chapter_name)) },
placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth().weight(1f),
state = originalTextFieldState,
label = { Text(stringResource(Res.string.original_text_markdown)) },
placeholder = { Text(stringResource(Res.string.paste_the_chapter_s_markdown_blank_lines)) },
)
Text(
// The chunk count is what the group is asked to sign
// alongside the chapter, so it is a count of the work
// being proposed rather than a curiosity about the text.
text = "$wordCount words · $characterCount characters · " +
"$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " +
"of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}",
style = MaterialTheme.typography.labelMedium,
color = if (tooManyChunks) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.typography.labelMedium.color
}
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = nameFieldState,
leadingIcon = {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = "Name of the chapter"
)
},
label = { Text(stringResource(Res.string.chapter_name)) },
placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth().weight(1f),
state = originalTextFieldState,
label = { Text(stringResource(Res.string.original_text_markdown)) },
placeholder = { Text(stringResource(Res.string.paste_the_chapter_s_markdown_blank_lines)) },
)
Text(
// The chunk count is what the group is asked to sign
// alongside the chapter, so it is a count of the work
// being proposed rather than a curiosity about the text.
text = "$wordCount words · $characterCount characters · " +
"$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " +
"of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}",
style = MaterialTheme.typography.labelMedium,
color = if (tooManyChunks) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.typography.labelMedium.color
}
)
}
}
}
}
AddChapterUIState.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.add_chapter),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
AddChapterUIState.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.add_chapter),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -70,6 +70,7 @@ import mantra.composeapp.generated.resources.propose_dialect
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -96,233 +97,235 @@ fun AddDialectScreen(
)
)
when (val addDialectUIState = addDialectViewModel.addDialectUIState) {
is AddDialectUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addDialectUIState.message,
)
}
}
is AddDialectUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = {
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
ScreenStateTransition(addDialectViewModel.addDialectUIState) { uiState ->
when (val addDialectUIState = uiState) {
is AddDialectUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the group. Nothing has been created
// yet -- the dialect appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed dialect")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose dialect"
)
Text(stringResource(Res.string.propose_dialect))
}
}
Text(
text = addDialectUIState.message,
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.add_a_dialect_the_group_can_translate_into))
}
is AddDialectUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
if (!addDialectUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"dialect into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = {
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the group. Nothing has been created
// yet -- the dialect appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed dialect")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose dialect"
)
Text(stringResource(Res.string.propose_dialect))
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.add_a_dialect_the_group_can_translate_into))
if (!addDialectUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"dialect into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.dialect_name)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_sesotho)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = countryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.country)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_lesotho)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = languageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.language)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_st)
)
},
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.dialect_name)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_sesotho)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = countryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.country)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_lesotho)
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = languageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = stringResource(Res.string.language)
)
},
placeholder = {
Text(
text = stringResource(Res.string.eg_st)
)
},
)
}
}
}
}
AddDialectUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
AddDialectUIState.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.add_dialect),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.add_dialect),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -49,6 +49,7 @@ import mantra.composeapp.generated.resources.to_join_the_chat_room
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -74,132 +75,134 @@ fun AddMemberToChatRoomConfirmationScreen(
)
)
when (val addMemberToChatRoomConfirmationUIState = addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) {
is AddMemberToChatRoomConfirmationUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addMemberToChatRoomConfirmationUIState.message,
textAlign = TextAlign.Center
)
}
}
is AddMemberToChatRoomConfirmationUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
// topBar = {
// TopAppBar(
// title = {
// val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) {
// "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}"
// } else {
// "Invite new member to chat"
// }
// Text(
// text = title,
// )
// },
// actions = {
// }
// )
// }
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
addMemberToChatRoomConfirmationViewModel.inviteToChatRoom(
localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom,
peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey,
peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage,
onInviteSent = onInviteSent
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Invite new member"
)
Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()))
}
},
actions = {
}
ScreenStateTransition(addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) { uiState ->
when (val addMemberToChatRoomConfirmationUIState = uiState) {
is AddMemberToChatRoomConfirmationUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addMemberToChatRoomConfirmationUIState.message,
textAlign = TextAlign.Center
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
}
is AddMemberToChatRoomConfirmationUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
// topBar = {
// TopAppBar(
// title = {
// val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) {
// "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}"
// } else {
// "Invite new member to chat"
// }
// Text(
// text = title,
// )
// },
// actions = {
// }
// )
// }
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
addMemberToChatRoomConfirmationViewModel.inviteToChatRoom(
localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom,
peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey,
peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage,
onInviteSent = onInviteSent
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Invite new member"
)
Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()))
}
},
actions = {
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Spacer(
modifier = Modifier.weight(1f)
)
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(stringResource(Res.string.invite))
Text(stringResource(Res.string.invite))
Text(
text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()
)
Text(
text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()
)
Text(
text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "")
)
Text(
text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "")
)
Text(
text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size),
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(3f)
)
Spacer(
modifier = Modifier.weight(3f)
)
}
}
}
}
}
AddMemberToChatRoomConfirmationUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
AddMemberToChatRoomConfirmationUIState.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.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.translate
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -97,209 +98,211 @@ fun AddTranslationArtifactVersionScreen(
)
)
when (val addTranslationUIState = addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) {
is AddTranslationArtifactVersionUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addTranslationUIState.message)
ScreenStateTransition(addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) { uiState ->
when (val addTranslationUIState = uiState) {
is AddTranslationArtifactVersionUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addTranslationUIState.message)
}
}
}
is AddTranslationArtifactVersionUIState.Loaded -> {
// A translation is into a dialect the group already signed into
// existence. Nothing is chosen to begin with: picking the first one
// for somebody would be choosing the language of the work.
var selectedDialectId: String? by remember { mutableStateOf(null) }
val selectedDialect = addTranslationUIState.dialects
.firstOrNull { dialect -> dialect.id == selectedDialectId }
is AddTranslationArtifactVersionUIState.Loaded -> {
// A translation is into a dialect the group already signed into
// existence. Nothing is chosen to begin with: picking the first one
// for somebody would be choosing the language of the work.
var selectedDialectId: String? by remember { mutableStateOf(null) }
val selectedDialect = addTranslationUIState.dialects
.firstOrNull { dialect -> dialect.id == selectedDialectId }
// A translation hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addTranslationUIState.artifactVersion
val chapterCount = addTranslationUIState.chapters.size
// A translation hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addTranslationUIState.artifactVersion
val chapterCount = addTranslationUIState.chapters.size
// The translation and a chapter apiece are signed together, and a
// session signs a bounded number of events -- so a long artifact is
// proposed in more than one, and the admins answer once per
// session. That is a number worth seeing before the tap.
val sessionCount = 1 + (
(chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION)
.coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1
) / FrostSigningManager.MAX_BATCH_SIZE
// The translation and a chapter apiece are signed together, and a
// session signs a bounded number of events -- so a long artifact is
// proposed in more than one, and the admins answer once per
// session. That is a number worth seeing before the tap.
val sessionCount = 1 + (
(chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION)
.coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1
) / FrostSigningManager.MAX_BATCH_SIZE
val canProposeTranslation = artifactVersion != null &&
selectedDialect != null &&
addTranslationUIState.canSign
val canProposeTranslation = artifactVersion != null &&
selectedDialect != null &&
addTranslationUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeTranslation) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeTranslation) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeTranslation) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) {
return@ExtendedFloatingActionButton
}
addTranslationArtifactVersionViewModel.addTranslation(
localChatRoom = addTranslationUIState.localChatRoom,
artifact = addTranslationUIState.artifact,
artifactVersion = artifactVersion,
dialect = selectedDialect,
chapters = addTranslationUIState.chapters,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the translation appears when
// enough members sign -- so landing on
// the list it is not in would read as a
// failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed translation")
)
}
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeTranslation) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeTranslation) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeTranslation) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) {
return@ExtendedFloatingActionButton
}
addTranslationArtifactVersionViewModel.addTranslation(
localChatRoom = addTranslationUIState.localChatRoom,
artifact = addTranslationUIState.artifact,
artifactVersion = artifactVersion,
dialect = selectedDialect,
chapters = addTranslationUIState.chapters,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the translation appears when
// enough members sign -- so landing on
// the list it is not in would read as a
// failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed translation")
)
}
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
}
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!addTranslationUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"translation into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = stringResource(Res.string.this_artifact_has_no_version_for_a),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Text(stringResource(Res.string.translate_into_which_dialect))
if (addTranslationUIState.dialects.isEmpty()) {
// The only way out of this screen, and it is not on it:
// a dialect is the group's too, and asking for one is
// its own quorum from the room's own detail screen.
Text(
text = "This group has no dialects yet. Add one from the group's " +
"details before translating anything into it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
addTranslationUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
if (!addTranslationUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"translation into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = stringResource(Res.string.this_artifact_has_no_version_for_a),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Text(
// What the group is being asked to sign, counted before
// the tap rather than described after it: the chapters
// are the rest of the batch. The empty case is worth
// its own sentence, so that a translation of an artifact
// nobody has written into yet does not read as broken.
text = if (chapterCount == 0) {
"The artifact has no chapters yet, so the group would sign the " +
"translation on its own. Chapters signed afterwards are put " +
"into it as they are added."
} else {
"The group signs the translation and " +
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" +
(if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") +
". Chunks are translated one at a time afterwards."
},
style = MaterialTheme.typography.labelMedium,
)
Text(stringResource(Res.string.translate_into_which_dialect))
if (addTranslationUIState.dialects.isEmpty()) {
// The only way out of this screen, and it is not on it:
// a dialect is the group's too, and asking for one is
// its own quorum from the room's own detail screen.
Text(
text = "This group has no dialects yet. Add one from the group's " +
"details before translating anything into it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
) {
addTranslationUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
}
Text(
// What the group is being asked to sign, counted before
// the tap rather than described after it: the chapters
// are the rest of the batch. The empty case is worth
// its own sentence, so that a translation of an artifact
// nobody has written into yet does not read as broken.
text = if (chapterCount == 0) {
"The artifact has no chapters yet, so the group would sign the " +
"translation on its own. Chapters signed afterwards are put " +
"into it as they are added."
} else {
"The group signs the translation and " +
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" +
(if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") +
". Chunks are translated one at a time afterwards."
},
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
AddTranslationArtifactVersionUIState.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.add_translation),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
AddTranslationArtifactVersionUIState.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.add_translation),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.words_characters
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -91,231 +92,233 @@ fun ArtifactDetailScreen(
)
)
when (val artifactDetailUIState = artifactDetailViewModel.artifactDetailUIState) {
is ArtifactDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = artifactDetailUIState.message)
}
}
is ArtifactDetailUIState.Loaded -> {
val artifact = artifactDetailUIState.artifact
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(artifact.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
ScreenStateTransition(artifactDetailViewModel.artifactDetailUIState) { uiState ->
when (val artifactDetailUIState = uiState) {
is ArtifactDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Chapters
item {
Text(
text = stringResource(Res.string.chapters),
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.chapters.isEmpty()) {
item { Text(stringResource(Res.string.no_chapters_yet)) }
} else {
items(
items = artifactDetailUIState.chapters,
key = { chapter -> chapter.id }
) { chapter ->
Card(
onClick = {
onNavigateToRoute.invoke(
ChapterDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chapterId = chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = artifactDetailUIState.message)
}
}
is ArtifactDetailUIState.Loaded -> {
val artifact = artifactDetailUIState.artifact
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(artifact.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
// Chapters
item {
Text(
text = stringResource(Res.string.chapters),
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.chapters.isEmpty()) {
item { Text(stringResource(Res.string.no_chapters_yet)) }
} else {
items(
items = artifactDetailUIState.chapters,
key = { chapter -> chapter.id }
) { chapter ->
Card(
onClick = {
onNavigateToRoute.invoke(
ChapterDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chapterId = chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
},
overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) },
headlineContent = { Text(chapter.name) },
supportingContent = {
Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount))
}
)
}
}
}
item {
TextButton(
enabled = artifactDetailUIState.versions.isNotEmpty(),
onClick = {
onNavigateToRoute.invoke(
AddChapterRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
)
},
overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) },
headlineContent = { Text(chapter.name) },
supportingContent = {
Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount))
}
)
)
}
}
) {
Icon(
Icons.Default.PostAdd,
contentDescription = "Add chapter"
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.add_chapter))
}
}
item { HorizontalDivider() }
// Translations
item {
Text(
text = stringResource(Res.string.translations),
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.translations.isEmpty()) {
item { Text(stringResource(Res.string.no_translations_yet)) }
} else {
items(
items = artifactDetailUIState.translations,
key = { translation -> translation.id }
) { translation ->
Card(
item {
TextButton(
enabled = artifactDetailUIState.versions.isNotEmpty(),
onClick = {
onNavigateToRoute.invoke(
TranslationArtifactVersionDetailRoute(
AddChapterRoute(
activeUserPublicKey = activeUserPublicKey,
translationArtifactVersionId = translation.id,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Translation"
)
},
headlineContent = { Text(translation.name) },
supportingContent = { Text(translation.visibility) }
Icon(
Icons.Default.PostAdd,
contentDescription = "Add chapter"
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.add_chapter))
}
}
}
item {
TextButton(
enabled = artifactDetailUIState.versions.isNotEmpty(),
onClick = {
onNavigateToRoute.invoke(
AddTranslationRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add translation"
item { HorizontalDivider() }
// Translations
item {
Text(
text = stringResource(Res.string.translations),
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.add_translation))
}
}
item { HorizontalDivider() }
// Details
item {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
DetailRow(label = "Name", value = artifact.name)
DetailRow(label = "Url", value = artifact.url)
DetailRow(label = "Visibility", value = artifact.visibility)
DetailRow(label = "License", value = artifact.license)
DetailRow(label = "Dialect", value = artifact.dialectId)
DetailRow(label = "Author", value = artifact.publicKey)
DetailRow(label = "Created", value = artifact.createdAt.toString())
if (artifactDetailUIState.translations.isEmpty()) {
item { Text(stringResource(Res.string.no_translations_yet)) }
} else {
items(
items = artifactDetailUIState.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 = "Translation"
)
},
headlineContent = { Text(translation.name) },
supportingContent = { Text(translation.visibility) }
)
}
}
}
}
// Versions
item {
Text(
text = stringResource(Res.string.versions),
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.versions.isEmpty()) {
item { Text(stringResource(Res.string.no_versions)) }
} else {
items(
items = artifactDetailUIState.versions,
key = { version -> version.id }
) { version ->
Card {
ListItem(
headlineContent = { Text(version.versionLabel) },
overlineContent = { Text(stringResource(Res.string.version)) }
item {
TextButton(
enabled = artifactDetailUIState.versions.isNotEmpty(),
onClick = {
onNavigateToRoute.invoke(
AddTranslationRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add translation"
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.add_translation))
}
}
item { HorizontalDivider() }
// Details
item {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
DetailRow(label = "Name", value = artifact.name)
DetailRow(label = "Url", value = artifact.url)
DetailRow(label = "Visibility", value = artifact.visibility)
DetailRow(label = "License", value = artifact.license)
DetailRow(label = "Dialect", value = artifact.dialectId)
DetailRow(label = "Author", value = artifact.publicKey)
DetailRow(label = "Created", value = artifact.createdAt.toString())
}
}
// Versions
item {
Text(
text = stringResource(Res.string.versions),
style = MaterialTheme.typography.labelMedium
)
}
if (artifactDetailUIState.versions.isEmpty()) {
item { Text(stringResource(Res.string.no_versions)) }
} else {
items(
items = artifactDetailUIState.versions,
key = { version -> version.id }
) { version ->
Card {
ListItem(
headlineContent = { Text(version.versionLabel) },
overlineContent = { Text(stringResource(Res.string.version)) }
)
}
}
}
}
}
}
}
ArtifactDetailUIState.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.artifact_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
ArtifactDetailUIState.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.artifact_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -51,6 +51,7 @@ import mantra.composeapp.generated.resources.words_characters
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,114 +75,116 @@ fun ChapterDetailScreen(
)
)
when (val chapterDetailUIState = chapterDetailViewModel.chapterDetailUIState) {
is ChapterDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = chapterDetailUIState.message)
}
}
is ChapterDetailUIState.Loaded -> {
val chapter = chapterDetailUIState.chapter
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(chapter.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
ScreenStateTransition(chapterDetailViewModel.chapterDetailUIState) { uiState ->
when (val chapterDetailUIState = uiState) {
is ChapterDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text(
text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount),
style = MaterialTheme.typography.labelMedium
)
}
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = chapterDetailUIState.message)
}
}
// Original text (markdown, shown as-is).
item {
Text(
text = stringResource(Res.string.original_text),
style = MaterialTheme.typography.labelMedium
is ChapterDetailUIState.Loaded -> {
val chapter = chapterDetailUIState.chapter
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(chapter.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
item {
Card {
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
item {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.containerPadding),
text = chapter.originalText,
style = MaterialTheme.typography.bodyMedium
text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount),
style = MaterialTheme.typography.labelMedium
)
}
}
item { HorizontalDivider() }
// Chunks
item {
Text(
text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size),
style = MaterialTheme.typography.labelMedium
)
}
if (chapterDetailUIState.chunks.isEmpty()) {
item { Text(stringResource(Res.string.no_chunks)) }
} else {
items(
items = chapterDetailUIState.chunks,
key = { chunk -> chunk.id }
) { chunk ->
// Original text (markdown, shown as-is).
item {
Text(
text = stringResource(Res.string.original_text),
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Segment,
contentDescription = "Chunk"
)
},
overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) },
headlineContent = { Text(chunk.text) },
supportingContent = {
Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount))
}
Text(
modifier = Modifier.padding(MaterialTheme.spacing.containerPadding),
text = chapter.originalText,
style = MaterialTheme.typography.bodyMedium
)
}
}
item { HorizontalDivider() }
// Chunks
item {
Text(
text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size),
style = MaterialTheme.typography.labelMedium
)
}
if (chapterDetailUIState.chunks.isEmpty()) {
item { Text(stringResource(Res.string.no_chunks)) }
} else {
items(
items = chapterDetailUIState.chunks,
key = { chunk -> chunk.id }
) { chunk ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Segment,
contentDescription = "Chunk"
)
},
overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) },
headlineContent = { Text(chunk.text) },
supportingContent = {
Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount))
}
)
}
}
}
}
}
}
}
ChapterDetailUIState.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.chapter_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
ChapterDetailUIState.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.chapter_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -95,6 +95,7 @@ import mantra.composeapp.generated.resources.recovered_of_still_unreadable
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -121,262 +122,297 @@ fun ChatRoomDetailScreen(
)
)
when (val chatRoomDetailUIState = chatRoomDetailViewModel.chatRoomDetailUIState) {
is ChatRoomDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
)
}
}
is ChatRoomDetailUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText()
},
ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState ->
when (val chatRoomDetailUIState = uiState) {
is ChatRoomDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
}
is ChatRoomDetailUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText()
},
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
item {
chatRoomDetailUIState.localChatRoom.chatRoom.description?.let {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
item {
chatRoomDetailUIState.localChatRoom.chatRoom.description?.let {
Text(
text = it,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge
)
}
}
item {
HorizontalDivider()
}
item {
// Artifacts
Text(
text = it,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge
text = stringResource(Res.string.library),
style = MaterialTheme.typography.labelMedium
)
}
}
item {
HorizontalDivider()
}
item {
// Artifacts
Text(
text = stringResource(Res.string.library),
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.artifacts.isEmpty()) {
item {
Text(stringResource(Res.string.no_artifacts_exists_in_this_groups_library))
if (chatRoomDetailUIState.artifacts.isEmpty()) {
item {
Text(stringResource(Res.string.no_artifacts_exists_in_this_groups_library))
}
} else {
items(
items = chatRoomDetailUIState.artifacts,
key = { artifact -> artifact.id }
) { artifact ->
Card(
onClick = {
onNavigateToRoute.invoke(
ArtifactDetailRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.LibraryBooks,
contentDescription = "Artifact"
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = "View artifact"
)
},
headlineContent = {
Text(text = artifact.name)
},
supportingContent = {
Text(text = artifact.url)
}
)
}
}
}
} else {
items(
items = chatRoomDetailUIState.artifacts,
key = { artifact -> artifact.id }
) { artifact ->
Card(
item {
TextButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
ArtifactDetailRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
AddArtifactRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.LibraryBooks,
contentDescription = "Artifact"
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = "View artifact"
)
},
headlineContent = {
Text(text = artifact.name)
},
supportingContent = {
Text(text = artifact.url)
}
Icon(
Icons.Default.LibraryBooks,
contentDescription = "Add new artifact"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.add_artifact_to_library))
}
}
}
item {
TextButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
AddArtifactRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.LibraryBooks,
contentDescription = "Add new artifact"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.add_artifact_to_library))
}
}
item {
HorizontalDivider()
}
item {
// Dialects
Text(
text = stringResource(Res.string.dialects),
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.dialects.isEmpty()) {
item {
Text(stringResource(Res.string.no_dialects_have_been_defined_in_this_group_2))
HorizontalDivider()
}
} else {
items(
items = chatRoomDetailUIState.dialects,
key = { dialect -> dialect.id }
) { dialect ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Dialect"
)
},
headlineContent = {
Text(text = dialect.name)
},
supportingContent = {
Text(text = "${dialect.language} \u00b7 ${dialect.country}")
}
)
}
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
AddDialectRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
item {
// Dialects
Text(
text = stringResource(Res.string.dialects),
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.dialects.isEmpty()) {
item {
Text(stringResource(Res.string.no_dialects_have_been_defined_in_this_group_2))
}
} else {
items(
items = chatRoomDetailUIState.dialects,
key = { dialect -> dialect.id }
) { dialect ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Dialect"
)
},
headlineContent = {
Text(text = dialect.name)
},
supportingContent = {
Text(text = "${dialect.language} \u00b7 ${dialect.country}")
}
)
)
}
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add new dialect"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.add_dialect))
}
}
item {
HorizontalDivider()
}
// TODO: Add projects...
item {
// Artifacts
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"
)
)
}
) {
Icon(
Icons.Default.Schema,
contentDescription = "create new projec"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.create_project))
}
}
item {
HorizontalDivider()
}
item {
Text(
text = stringResource(Res.string.members_2),
style = MaterialTheme.typography.labelMedium
)
}
// A shared threshold key only means something where every
// member is an equal admin, which is the NIP-17 (robust) case.
// MLS rooms have one admin and no group key to share.
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) {
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
DkgRitualRoute(
AddDialectRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add new dialect"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.add_dialect))
}
}
item {
HorizontalDivider()
}
// TODO: Add projects...
item {
// Artifacts
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"
)
)
}
) {
Icon(
Icons.Default.Schema,
contentDescription = "create new projec"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.create_project))
}
}
item {
HorizontalDivider()
}
item {
Text(
text = stringResource(Res.string.members_2),
style = MaterialTheme.typography.labelMedium
)
}
// A shared threshold key only means something where every
// member is an equal admin, which is the NIP-17 (robust) case.
// MLS rooms have one admin and no group key to share.
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) {
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
DkgRitualRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
) {
Icon(
Icons.Default.Key,
contentDescription = "Shared key"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.shared_key))
}
}
}
// Every room, not only the one that holds the key. A
// ceremony needs a NIP-17 group, but a signing message is
// a marmot inner event -- see `FrostSigningManager.broadcast`
// -- so the room a group actually proposes in is the MLS
// one, which is the last place this should be missing from.
//
// The transcript carries a proposal past as it happens;
// this is where a member goes to find one that has scrolled
// away, or to see what the group has signed.
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
@@ -384,263 +420,230 @@ fun ChatRoomDetailScreen(
}
) {
Icon(
Icons.Default.Key,
contentDescription = "Shared key"
Icons.Default.Draw,
contentDescription = "Proposals"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.shared_key))
Text(stringResource(Res.string.proposals))
}
}
}
// Every room, not only the one that holds the key. A
// ceremony needs a NIP-17 group, but a signing message is
// a marmot inner event -- see `FrostSigningManager.broadcast`
// -- so the room a group actually proposes in is the MLS
// one, which is the last place this should be missing from.
//
// The transcript carries a proposal past as it happens;
// this is where a member goes to find one that has scrolled
// away, or to see what the group has signed.
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
) {
Icon(
Icons.Default.Draw,
contentDescription = "Proposals"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.proposals))
}
}
item {
TextButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
SearchMemberToAddToChatRoomRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = "Invite new member"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.invite_new_member))
}
}
items(
items = chatRoomDetailUIState.localChatRoom.localParticipants,
key = { localParticipant -> localParticipant.participant.participantPublicKey}
) { localParticipant ->
Card(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Profile detail"
)
)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = localParticipant.participant.participantPublicKey,
profile = localParticipant.profile
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = "View profile"
)
},
headlineContent = {
Text(
text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey
)
},
supportingContent = {
localParticipant.profile?.about?.let {
Text(
text = it
item {
TextButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
SearchMemberToAddToChatRoomRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
}
)
}
) {
)
Icon(
Icons.Default.PersonAdd,
contentDescription = "Invite new member"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.invite_new_member))
}
}
}
items(
items = chatRoomDetailUIState.localChatRoom.localParticipants,
key = { localParticipant -> localParticipant.participant.participantPublicKey}
) { localParticipant ->
Card(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Profile detail"
)
)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = localParticipant.participant.participantPublicKey,
profile = localParticipant.profile
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = "View profile"
)
},
headlineContent = {
Text(
text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey
)
},
supportingContent = {
localParticipant.profile?.about?.let {
Text(
text = it
)
}
}
item {
HorizontalDivider()
}
item {
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.secondary
),
enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group...
onClick = {
chatRoomDetailViewModel.leaveGroup(
localChatRoom = chatRoomDetailUIState.localChatRoom,
onPopBackToRoute = onPopBackToRoute
)
}
) {
Icon(
Icons.Default.Unsubscribe,
contentDescription = "Leave group"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.leave_group))
}
// What it actually does: sets leftGroupAt and posts a line to
// the room. Said here because the button fires immediately --
// there is no confirmation step to say it in.
Text(
text = "Posts a line to the room saying you left, and " +
"lets you delete it from this device afterwards.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = MaterialTheme.spacing.space600,
end = MaterialTheme.spacing.containerPadding,
bottom = MaterialTheme.spacing.compactPadding,
)
)
}
item {
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null,
onClick = {
chatRoomDetailViewModel.softDeleteGroup(
localChatRoom = chatRoomDetailUIState.localChatRoom,
onPopBackToRoute = onPopBackToRoute
)
}
) {
Icon(
Icons.Default.DeleteForever,
contentDescription = "Delete group"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.delete_group))
}
// A soft delete: it sets deletedAt on the local row. Saying
// "delete" without saying where would leave somebody believing
// the messages are gone from the relays and from the other
// members, which is the opposite of true.
Text(
text = "Removes the room from this device. The messages " +
"stay on the relays and with the other members.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = MaterialTheme.spacing.space600,
end = MaterialTheme.spacing.containerPadding,
bottom = MaterialTheme.spacing.compactPadding,
)
)
}
// Only MLS rooms have group events to read again. A NIP-17
// room's messages are gift wraps, which carry no ordering
// for anything to go wrong with.
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) {
item {
HorizontalDivider()
}
item {
ReindexMarmotGroupEventsButton(
reindexState = chatRoomDetailViewModel.reindexState,
onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.secondary
),
enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group...
onClick = {
chatRoomDetailViewModel.leaveGroup(
localChatRoom = chatRoomDetailUIState.localChatRoom,
onPopBackToRoute = onPopBackToRoute
)
}
) {
Icon(
Icons.Default.Unsubscribe,
contentDescription = "Leave group"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.leave_group))
}
// What it actually does: sets leftGroupAt and posts a line to
// the room. Said here because the button fires immediately --
// there is no confirmation step to say it in.
Text(
text = "Posts a line to the room saying you left, and " +
"lets you delete it from this device afterwards.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = MaterialTheme.spacing.space600,
end = MaterialTheme.spacing.containerPadding,
bottom = MaterialTheme.spacing.compactPadding,
)
)
}
item {
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null,
onClick = {
chatRoomDetailViewModel.softDeleteGroup(
localChatRoom = chatRoomDetailUIState.localChatRoom,
onPopBackToRoute = onPopBackToRoute
)
}
) {
Icon(
Icons.Default.DeleteForever,
contentDescription = "Delete group"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.delete_group))
}
// A soft delete: it sets deletedAt on the local row. Saying
// "delete" without saying where would leave somebody believing
// the messages are gone from the relays and from the other
// members, which is the opposite of true.
Text(
text = "Removes the room from this device. The messages " +
"stay on the relays and with the other members.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = MaterialTheme.spacing.space600,
end = MaterialTheme.spacing.containerPadding,
bottom = MaterialTheme.spacing.compactPadding,
)
)
}
// Only MLS rooms have group events to read again. A NIP-17
// room's messages are gift wraps, which carry no ordering
// for anything to go wrong with.
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) {
item {
HorizontalDivider()
}
item {
ReindexMarmotGroupEventsButton(
reindexState = chatRoomDetailViewModel.reindexState,
onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents
)
}
}
}
}
}
}
}
ChatRoomDetailUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
ChatRoomDetailUIState.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.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -70,6 +70,7 @@ import mantra.composeapp.generated.resources.private_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -95,355 +96,357 @@ fun ChatRoomMessagingScreen(
)
)
when (val chatRoomDetailUIState = chatRoomMessagingViewModel.chatRoomMessagingUIState) {
is ChatRoomMessagingUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
)
}
}
is ChatRoomMessagingUIState.Loaded -> {
val textFieldState = rememberTextFieldState()
val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel(
factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory(
localChatRoom = chatRoomDetailUIState.localChatRoom,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
IconButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
ChatRoomDetailRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "View details..."
)
}
}
ScreenStateTransition(chatRoomMessagingViewModel.chatRoomMessagingUIState) { uiState ->
when (val chatRoomDetailUIState = uiState) {
is ChatRoomMessagingUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
key(true) {
ChatTranscript(
viewModel = chatMessageListViewModel,
onOpenSharedKey = {
onNavigateToRoute.invoke(
DkgRitualRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
}
is ChatRoomMessagingUIState.Loaded -> {
val textFieldState = rememberTextFieldState()
val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel(
factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory(
localChatRoom = chatRoomDetailUIState.localChatRoom,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
IconButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
ChatRoomDetailRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.MoreVert,
contentDescription = "View details..."
)
},
onOpenSigning = { sessionId ->
// Two reasons to open the queue instead of the one
// proposal. A line written before chat rows named
// their session cannot say which proposal it is
// about, and the room may have several -- the list
// is the honest answer there, showing all of them
// with their own state rather than guessing at one.
//
// The other is that the proposal they tapped is
// not the only one waiting on them, in which case
// it is not the whole of what is being asked and a
// screen showing only it would say it was.
onNavigateToRoute.invoke(
if (sessionId == null ||
chatMessageListViewModel.hidesOtherDecisions(sessionId)
) {
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
key(true) {
ChatTranscript(
viewModel = chatMessageListViewModel,
onOpenSharedKey = {
onNavigateToRoute.invoke(
DkgRitualRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
},
onOpenSigning = { sessionId ->
// Two reasons to open the queue instead of the one
// proposal. A line written before chat rows named
// their session cannot say which proposal it is
// about, and the room may have several -- the list
// is the honest answer there, showing all of them
// with their own state rather than guessing at one.
//
// The other is that the proposal they tapped is
// not the only one waiting on them, in which case
// it is not the whole of what is being asked and a
// screen showing only it would say it was.
onNavigateToRoute.invoke(
if (sessionId == null ||
chatMessageListViewModel.hidesOtherDecisions(sessionId)
) {
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
} else {
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
}
)
},
onOpenProposals = {
onNavigateToRoute.invoke(
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
} else {
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
}
)
},
onOpenProposals = {
onNavigateToRoute.invoke(
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}
}
key(true) {
chatMessageListViewModel.initiate()
}
// TODO: Check that we have direct message relays for this user...
// The composer handles its own bottom inset, which is why this
// screen is the one text-field screen without `imePadding()` on its
// Scaffold. Adding it here would pad twice while the keyboard is up:
// the ime inset already covers the navigation bar area this row is
// separately reserving. Getting the combination right wants a device
// with a keyboard open, not a compiler.
Column(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor)
.navigationBarsPadding()
) {
// An armed composer has to be impossible to miss. The whole risk
// of this feature is somebody sending to the room what they meant
// for one person, or the reverse, and by the time either is on the
// wire it cannot be taken back. Hence three signals at once: the
// chip, the placeholder, and the tinted field.
val directMessageRecipient = chatMessageListViewModel.directMessageRecipient
if (directMessageRecipient != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = MaterialTheme.spacing.containerPadding, vertical = MaterialTheme.spacing.space75),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
}
)
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelMedium
)
// The one way back to the room, and it has to be one tap.
IconButton(
onClick = { chatMessageListViewModel.cancelDirectMessage() }
) {
Icon(
Icons.Default.Close,
contentDescription = "Send to the whole group instead",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = textFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
focusedContainerColor = if (directMessageRecipient != null) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
Color.Transparent
},
unfocusedContainerColor = if (directMessageRecipient != null) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
Color.Transparent
}
),
placeholder = {
Text(
text = if (directMessageRecipient != null) {
stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey))
} else {
stringResource(Res.string.say_what_now)
}
)
},
trailingIcon = {
if (textFieldState.text.isEmpty()) {
IconButton(
onClick = {
}
) {
Icon(
Icons.Default.Mic,
contentDescription = "Voice"
)
}
} else {
IconButton(
onClick = {
chatMessageListViewModel.sendMessage(
textFieldState = textFieldState
)
}
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send"
)
}
}
}
)
key(true) {
chatMessageListViewModel.initiate()
}
// Row(
// modifier = Modifier.padding(4.dp),
// horizontalArrangement = Arrangement.spacedBy(4.dp)
// ) {
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.InsertEmoticon,
// contentDescription = "Add emoji"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.AlternateEmail,
// contentDescription = "Mention profile"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.Collections,
// contentDescription = "Attach media"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.LocationOn,
// contentDescription = "Send location"
// )
// }
// }
// TODO: Check that we have direct message relays for this user...
// The composer handles its own bottom inset, which is why this
// screen is the one text-field screen without `imePadding()` on its
// Scaffold. Adding it here would pad twice while the keyboard is up:
// the ime inset already covers the navigation bar area this row is
// separately reserving. Getting the combination right wants a device
// with a keyboard open, not a compiler.
Column(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor)
.navigationBarsPadding()
) {
// An armed composer has to be impossible to miss. The whole risk
// of this feature is somebody sending to the room what they meant
// for one person, or the reverse, and by the time either is on the
// wire it cannot be taken back. Hence three signals at once: the
// chip, the placeholder, and the tinted field.
val directMessageRecipient = chatMessageListViewModel.directMessageRecipient
if (directMessageRecipient != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = MaterialTheme.spacing.containerPadding, vertical = MaterialTheme.spacing.space75),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
modifier = Modifier.weight(1f),
text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelMedium
)
// The one way back to the room, and it has to be one tap.
IconButton(
onClick = { chatMessageListViewModel.cancelDirectMessage() }
) {
Icon(
Icons.Default.Close,
contentDescription = "Send to the whole group instead",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = textFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
focusedContainerColor = if (directMessageRecipient != null) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
Color.Transparent
},
unfocusedContainerColor = if (directMessageRecipient != null) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
Color.Transparent
}
),
placeholder = {
Text(
text = if (directMessageRecipient != null) {
stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey))
} else {
stringResource(Res.string.say_what_now)
}
)
},
trailingIcon = {
if (textFieldState.text.isEmpty()) {
IconButton(
onClick = {
}
) {
Icon(
Icons.Default.Mic,
contentDescription = "Voice"
)
}
} else {
IconButton(
onClick = {
chatMessageListViewModel.sendMessage(
textFieldState = textFieldState
)
}
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send"
)
}
}
}
)
}
// Row(
// modifier = Modifier.padding(4.dp),
// horizontalArrangement = Arrangement.spacedBy(4.dp)
// ) {
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.InsertEmoticon,
// contentDescription = "Add emoji"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.AlternateEmail,
// contentDescription = "Mention profile"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.Collections,
// contentDescription = "Attach media"
// )
// }
//
// IconButton(
// onClick = {
//
// }
// ) {
// Icon(
// Icons.Default.LocationOn,
// contentDescription = "Send location"
// )
// }
// }
}
}
}
}
}
ChatRoomMessagingUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
ChatRoomMessagingUIState.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.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
ChatRoomMessagingUIState.InitiatingNewChat -> {
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.creating_new_chat),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
LaunchedEffect(true) {
chatRoomMessagingViewModel.initiateNewChat(
onNavigateToRouteAndPopUpInclusive
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.direct_message_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.direct_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
ChatRoomMessagingUIState.InitiatingNewChat -> {
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.creating_new_chat),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
LaunchedEffect(true) {
chatRoomMessagingViewModel.initiateNewChat(
onNavigateToRouteAndPopUpInclusive
)
}
}
}
}

View File

@@ -102,6 +102,7 @@ import mantra.composeapp.generated.resources.shared_key_for
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
/**
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -134,177 +135,179 @@ fun DkgRitualScreen(
)
)
when (val dkgRitualUIState = dkgRitualViewModel.dkgRitualUIState) {
is DkgRitualUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center)
ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState ->
when (val dkgRitualUIState = uiState) {
is DkgRitualUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center)
}
}
}
is DkgRitualUIState.Loaded -> {
val session = dkgRitualUIState.session
val participantCount = dkgRitualUIState.participantCount
val canStartRitual = dkgRitualViewModel.canStartRitual()
val isActionPending = dkgRitualViewModel.isActionPending.value
is DkgRitualUIState.Loaded -> {
val session = dkgRitualUIState.session
val participantCount = dkgRitualUIState.participantCount
val canStartRitual = dkgRitualViewModel.canStartRitual()
val isActionPending = dkgRitualViewModel.isActionPending.value
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
},
bottomBar = {
val pending = dkgRitualUIState.pendingApproval
// The ceremony is stopped, waiting on this member, and will stay
// stopped until they act -- so this outranks everything else the
// bar could offer.
if (pending != null) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
onNavigateToRoute(
when (pending) {
DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
}
)
}
) {
Icon(Icons.Default.Key, contentDescription = Decorative)
Text(
text = when (pending) {
DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join)
DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute)
DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm)
}
)
}
},
actions = {}
)
return@Scaffold
}
// Any member can open a ritual — the coordinator has no say in
// the key — but only when there isn't one already running.
if (canStartRitual) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { dkgRitualViewModel.startRitual() }
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Key, contentDescription = "Start")
}
Text(
text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again)
)
}
},
actions = {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount),
style = MaterialTheme.typography.labelLarge
text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (session == null) {
Text(
text = stringResource(Res.string.the_group_can_hold_one_key_together_split_so),
style = MaterialTheme.typography.bodyMedium
)
},
bottomBar = {
val pending = dkgRitualUIState.pendingApproval
Text(
text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount),
style = MaterialTheme.typography.bodyMedium
)
if (canStartRitual) {
QuorumStepper(
threshold = dkgRitualViewModel.threshold.value,
participantCount = participantCount,
quorumRange = dkgRitualViewModel.quorumRange(),
onThresholdChange = dkgRitualViewModel::setThreshold
// The ceremony is stopped, waiting on this member, and will stay
// stopped until they act -- so this outranks everything else the
// bar could offer.
if (pending != null) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
onNavigateToRoute(
when (pending) {
DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
}
)
}
) {
Icon(Icons.Default.Key, contentDescription = Decorative)
Text(
text = when (pending) {
DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join)
DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute)
DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm)
}
)
}
},
actions = {}
)
} else {
Text(
text = "A shared key needs at least " +
"${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.",
style = MaterialTheme.typography.labelLarge
return@Scaffold
}
// Any member can open a ritual — the coordinator has no say in
// the key — but only when there isn't one already running.
if (canStartRitual) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { dkgRitualViewModel.startRitual() }
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Key, contentDescription = "Start")
}
Text(
text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again)
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount),
style = MaterialTheme.typography.labelLarge
)
}
)
}
} else {
RitualProgress(
session = session,
participantCount = participantCount,
activeUserPublicKey = activeUserPublicKey,
members = dkgRitualUIState.ritualMembers,
hostKeyParticipants = dkgRitualUIState.hostKeyParticipants,
round1Participants = dkgRitualUIState.round1Participants,
round2Participants = dkgRitualUIState.round2Participants,
isActionPending = isActionPending,
adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn,
onCreateAdminGroup = {
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (session == null) {
Text(
text = stringResource(Res.string.the_group_can_hold_one_key_together_split_so),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount),
style = MaterialTheme.typography.bodyMedium
)
if (canStartRitual) {
QuorumStepper(
threshold = dkgRitualViewModel.threshold.value,
participantCount = participantCount,
quorumRange = dkgRitualViewModel.quorumRange(),
onThresholdChange = dkgRitualViewModel::setThreshold
)
} else {
Text(
text = "A shared key needs at least " +
"${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.",
style = MaterialTheme.typography.labelLarge
)
}
)
} else {
RitualProgress(
session = session,
participantCount = participantCount,
activeUserPublicKey = activeUserPublicKey,
members = dkgRitualUIState.ritualMembers,
hostKeyParticipants = dkgRitualUIState.hostKeyParticipants,
round1Participants = dkgRitualUIState.round1Participants,
round2Participants = dkgRitualUIState.round2Participants,
isActionPending = isActionPending,
adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn,
onCreateAdminGroup = {
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
}
)
}
}
}
}
}
DkgRitualUIState.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.shared_key),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
DkgRitualUIState.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.shared_key),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -74,6 +74,7 @@ import press.mantra.compose.ui.theme.breakpoint
import press.mantra.compose.ui.theme.listPaneWidthFor
import press.mantra.compose.ui.theme.readableContent
import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -126,213 +127,215 @@ fun HomeScreen(
),
)
when(val homeScreenUIState = homeScreenViewModel.homeScreenUIState) {
HomeScreenUIState.Error -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
ErrorState()
}
}
}
is HomeScreenUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier,
topBar = {
// No colour override. It was `containerColor = primaryContainer` with
// `titleContentColor = primary`, which in the light scheme is #000000
// on #1B1B1B -- 1.22:1, a black title on a near-black bar. The
// default is `surface`/`onSurface` and needs no help.
TopAppBar(
title = {
// "Mantra". The launcher label, the desktop window title, the
// landing screen and the package name all say so; this bar was
// the last place still saying "Torch". `UserAgent.APP_NAME` is
// the other, and is left alone -- it goes on the wire to relay
// operators, so it is a network identity question rather than
// a content one.
Text(stringResource(Res.string.mantra))
},
// No navigation icon and no actions. The avatar in the leading
// slot and the search icon in the trailing one were the app's only
// two peer surfaces, and they are now items in the navigation
// component -- a bar on a phone, a rail on a wide window. Leaving
// them here as well would be two routes to one destination, which
// is the thing M3's "swap only functionally equivalent components"
// caution is about.
)
},
floatingActionButton = {
// Only while there is one pane. With two, the scaffold's FAB slot is
// the bottom-right of the *window*, which is on top of the
// transcript's send button; M3 puts a list-detail layout's primary
// action in the list pane, and so does the branch below.
if (listPaneWidth == null) NewChatButton { showBottomSheet = true }
}
) { innerPadding ->
@Composable
fun ChatRoomListPane(modifier: Modifier) = Column(
modifier = modifier
) {
PrimaryScrollableTabRow(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
edgePadding = 10.dp,
selectedTabIndex = homeScreenViewModel.pagerState.currentPage,
ScreenStateTransition(homeScreenViewModel.homeScreenUIState) { uiState ->
when (val homeScreenUIState = uiState) {
HomeScreenUIState.Error -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
HomeScreenType.entries.forEachIndexed { index, destination ->
Tab(
selected = homeScreenViewModel.pagerState.currentPage == index,
onClick = {
scope.launch {
homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal)
ErrorState()
}
}
}
is HomeScreenUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier,
topBar = {
// No colour override. It was `containerColor = primaryContainer` with
// `titleContentColor = primary`, which in the light scheme is #000000
// on #1B1B1B -- 1.22:1, a black title on a near-black bar. The
// default is `surface`/`onSurface` and needs no help.
TopAppBar(
title = {
// "Mantra". The launcher label, the desktop window title, the
// landing screen and the package name all say so; this bar was
// the last place still saying "Torch". `UserAgent.APP_NAME` is
// the other, and is left alone -- it goes on the wire to relay
// operators, so it is a network identity question rather than
// a content one.
Text(stringResource(Res.string.mantra))
},
// No navigation icon and no actions. The avatar in the leading
// slot and the search icon in the trailing one were the app's only
// two peer surfaces, and they are now items in the navigation
// component -- a bar on a phone, a rail on a wide window. Leaving
// them here as well would be two routes to one destination, which
// is the thing M3's "swap only functionally equivalent components"
// caution is about.
)
},
floatingActionButton = {
// Only while there is one pane. With two, the scaffold's FAB slot is
// the bottom-right of the *window*, which is on top of the
// transcript's send button; M3 puts a list-detail layout's primary
// action in the list pane, and so does the branch below.
if (listPaneWidth == null) NewChatButton { showBottomSheet = true }
}
) { innerPadding ->
@Composable
fun ChatRoomListPane(modifier: Modifier) = Column(
modifier = modifier
) {
PrimaryScrollableTabRow(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
edgePadding = 10.dp,
selectedTabIndex = homeScreenViewModel.pagerState.currentPage,
) {
HomeScreenType.entries.forEachIndexed { index, destination ->
Tab(
selected = homeScreenViewModel.pagerState.currentPage == index,
onClick = {
scope.launch {
homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal)
}
},
text = {
Text(
text = destination.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
HorizontalPager(
state = homeScreenViewModel.pagerState,
) { pageIndex ->
when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) {
HomeScreenType.Messages -> {
val chatRoomListViewModel: ChatRoomListViewModel = viewModel(
key = homeScreenTypeType.name,
factory = ChatRoomListViewModel.factory(
initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading,
publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey,
nostrRepository = nostrRepository,
chatRepository = chatRepository
),
)
key(true) {
chatRoomListViewModel.RenderFeed(
onNavigateToDirectMessageDetail = onOpenChatRoom
)
}
key(true) {
chatRoomListViewModel.initiate()
}
}
},
text = {
Text(
text = destination.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
if (listPaneWidth == null) {
// Compact and medium: the list is the screen, and a room is a route.
// Byte for byte the layout this screen has always had.
ChatRoomListPane(
Modifier.padding(innerPadding).readableContent().fillMaxSize()
)
} else {
// Expanded and up: the list keeps a fixed width and the room fills the
// rest. No `readableContent()` on the pair -- capping the two panes
// together to one column's measure is the opposite of what a second
// pane is for. Each pane holds its own content to a measure instead,
// and `ChatRoomMessagingScreen` already does.
Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
Box(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight()
// The pane's width is the one thing about this layout that
// is a number rather than a rule, and the only way to check
// a number is to measure it in a composition.
.testTag(ChatListPaneTag)
) {
ChatRoomListPane(Modifier.fillMaxSize())
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(MaterialTheme.spacing.containerPadding)
) {
NewChatButton { showBottomSheet = true }
}
}
Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
val chatRoomId = selectedChatRoomId
if (chatRoomId == null) {
EmptyState(
message = stringResource(Res.string.pick_a_conversation_to_read_it_here)
)
} else {
// Keyed on the room, so switching rooms rebuilds the
// screen's view models rather than feeding a new id to
// ones already subscribed to another room's relay.
key(chatRoomId) {
ChatRoomMessagingScreen(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = selectedRelayHint,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
// "Replace what is on screen" is a route push on a
// phone and a change of selection here. It fires when
// a conversation that did not exist yet has just been
// created and has an id at last.
onNavigateToRouteAndPopUpInclusive = onOpenChatRoom,
onNavigateToRoute = onNavigateToRoute,
)
}
}
}
}
}
if (showBottomSheet) {
NewChatBottomSheetDialog(
scope = scope,
sheetState = sheetState,
onSetShowBottomSheetUpdate = { toggle ->
showBottomSheet = toggle
},
onNavigateToRoute = onNavigateToRoute,
onNavigateToChatRoomCreation = onNavigateToChatRoomCreation,
onStartDirectMessageChatWithNpub = {
openNpubDialog.value = true
}
)
}
when {
openNpubDialog.value -> {
StartDirectMessageToNpubOrNip05Dialog(
activeUserPublicKey = activeUserPublicKey,
scope = scope,
toggleOpenDialogSetting = {
openNpubDialog.value = false
},
onNavigateToRoute = onNavigateToRoute
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
HorizontalPager(
state = homeScreenViewModel.pagerState,
) { pageIndex ->
when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) {
HomeScreenType.Messages -> {
val chatRoomListViewModel: ChatRoomListViewModel = viewModel(
key = homeScreenTypeType.name,
factory = ChatRoomListViewModel.factory(
initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading,
publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey,
nostrRepository = nostrRepository,
chatRepository = chatRepository
),
)
key(true) {
chatRoomListViewModel.RenderFeed(
onNavigateToDirectMessageDetail = onOpenChatRoom
)
}
key(true) {
chatRoomListViewModel.initiate()
}
}
}
}
}
}
if (listPaneWidth == null) {
// Compact and medium: the list is the screen, and a room is a route.
// Byte for byte the layout this screen has always had.
ChatRoomListPane(
Modifier.padding(innerPadding).readableContent().fillMaxSize()
)
} else {
// Expanded and up: the list keeps a fixed width and the room fills the
// rest. No `readableContent()` on the pair -- capping the two panes
// together to one column's measure is the opposite of what a second
// pane is for. Each pane holds its own content to a measure instead,
// and `ChatRoomMessagingScreen` already does.
Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
Box(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight()
// The pane's width is the one thing about this layout that
// is a number rather than a rule, and the only way to check
// a number is to measure it in a composition.
.testTag(ChatListPaneTag)
) {
ChatRoomListPane(Modifier.fillMaxSize())
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(MaterialTheme.spacing.containerPadding)
) {
NewChatButton { showBottomSheet = true }
}
}
Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
val chatRoomId = selectedChatRoomId
if (chatRoomId == null) {
EmptyState(
message = stringResource(Res.string.pick_a_conversation_to_read_it_here)
)
} else {
// Keyed on the room, so switching rooms rebuilds the
// screen's view models rather than feeding a new id to
// ones already subscribed to another room's relay.
key(chatRoomId) {
ChatRoomMessagingScreen(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = selectedRelayHint,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
// "Replace what is on screen" is a route push on a
// phone and a change of selection here. It fires when
// a conversation that did not exist yet has just been
// created and has an id at last.
onNavigateToRouteAndPopUpInclusive = onOpenChatRoom,
onNavigateToRoute = onNavigateToRoute,
)
}
}
}
}
}
if (showBottomSheet) {
NewChatBottomSheetDialog(
scope = scope,
sheetState = sheetState,
onSetShowBottomSheetUpdate = { toggle ->
showBottomSheet = toggle
},
onNavigateToRoute = onNavigateToRoute,
onNavigateToChatRoomCreation = onNavigateToChatRoomCreation,
onStartDirectMessageChatWithNpub = {
openNpubDialog.value = true
}
)
}
when {
openNpubDialog.value -> {
StartDirectMessageToNpubOrNip05Dialog(
activeUserPublicKey = activeUserPublicKey,
scope = scope,
toggleOpenDialogSetting = {
openNpubDialog.value = false
},
onNavigateToRoute = onNavigateToRoute
)
}
}
}
}
HomeScreenUIState.Loading -> {
LoadingDataIndicator()
HomeScreenUIState.Loading -> {
LoadingDataIndicator()
}
}
}

View File

@@ -59,6 +59,7 @@ import press.mantra.compose.ui.composable.widgets.rememberNotifier
import mantra.composeapp.generated.resources.key_package_published
import mantra.composeapp.generated.resources.key_package_rotated
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -86,155 +87,157 @@ fun KeyPackageManagementScreen(
),
)
when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) {
KeyPackageManagementUIState.Error -> {
ErrorState()
}
is KeyPackageManagementUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.key_package_management)
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Button(
onClick = {
keyPackageManagementViewModel.publishNewKeyPackage {
notify(publishedMessage)
}
}
) {
Icon(
Icons.Default.LockReset,
contentDescription = "Publish new key package"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
ScreenStateTransition(keyPackageManagementViewModel.keyPackageManagementUIState) { uiState ->
when (val keyPackageManagementUIState = uiState) {
KeyPackageManagementUIState.Error -> {
ErrorState()
}
is KeyPackageManagementUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.publish_new_key_package)
text = stringResource(Res.string.key_package_management)
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Button(
onClick = {
keyPackageManagementViewModel.publishNewKeyPackage {
notify(publishedMessage)
}
}
) {
Icon(
Icons.Default.LockReset,
contentDescription = "Publish new key package"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
text = stringResource(Res.string.publish_new_key_package)
)
}
}
item {
Text(
stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size)
)
}
}
item {
Text(
stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size)
)
}
items(
items = keyPackageManagementUIState.keyPackageBundles
) { marmotKeyPackageBundle ->
items(
items = keyPackageManagementUIState.keyPackageBundles
) { marmotKeyPackageBundle ->
Card {
ListItem(
leadingContent = {
if (marmotKeyPackageBundle.rotated) {
Icon(
Icons.Default.Unpublished,
contentDescription = "Key package rotated"
)
} else if (marmotKeyPackageBundle.consumed) {
Icon(
Icons.Default.DoneAll,
contentDescription = "Consumed key package event"
)
} else {
// TODO: Get broadcast status of key package...
Icon(
Icons.Default.VerifiedUser,
contentDescription = "Key package published"
)
}
},
headlineContent = {
Text(
text = marmotKeyPackageBundle.id,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
},
supportingContent = {
Row(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
Spacer(
modifier = Modifier.weight(1f)
)
}
},
trailingContent = {
if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) {
IconButton(
onClick = {
keyPackageManagementViewModel.rotateKeyPackage(
marmotKeyPackageBundle
) {
notify(rotatedMessage)
}
}
) {
Card {
ListItem(
leadingContent = {
if (marmotKeyPackageBundle.rotated) {
Icon(
Icons.Default.LockReset,
contentDescription = "Rotate key"
Icons.Default.Unpublished,
contentDescription = "Key package rotated"
)
} else if (marmotKeyPackageBundle.consumed) {
Icon(
Icons.Default.DoneAll,
contentDescription = "Consumed key package event"
)
} else {
// TODO: Get broadcast status of key package...
Icon(
Icons.Default.VerifiedUser,
contentDescription = "Key package published"
)
}
}
},
headlineContent = {
Text(
text = marmotKeyPackageBundle.id,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
},
supportingContent = {
Row(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
}
)
Spacer(
modifier = Modifier.weight(1f)
)
}
},
trailingContent = {
if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) {
IconButton(
onClick = {
keyPackageManagementViewModel.rotateKeyPackage(
marmotKeyPackageBundle
) {
notify(rotatedMessage)
}
}
) {
Icon(
Icons.Default.LockReset,
contentDescription = "Rotate key"
)
}
}
}
)
}
}
}
}
}
}
}
KeyPackageManagementUIState.Loading -> {
LoadingDataIndicator()
}
KeyPackageManagementUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
KeyPackageManagementUIState.Loading -> {
LoadingDataIndicator()
}
KeyPackageManagementUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
}
}
}
}

View File

@@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,78 +67,80 @@ fun NostrEventDetailScreen(
),
)
when(val feedListUIState = nostrEventDetailViewModel.nostrEventDetailUIState) {
NostrEventDetailUIState.Error -> {
ErrorState()
}
is NostrEventDetailUIState.Loaded -> {
ScreenStateTransition(nostrEventDetailViewModel.nostrEventDetailUIState) { uiState ->
when (val feedListUIState = uiState) {
NostrEventDetailUIState.Error -> {
ErrorState()
}
is NostrEventDetailUIState.Loaded -> {
when(feedListUIState.localNostrEvent.nostrEvent.kind) {
MetadataEvent.KIND -> {
MetadataEventDetail(
activeUserPublicKey = activeUserPublicKey,
localNostrEvent = feedListUIState.localNostrEvent,
onNavigateBack = onNavigateBack,
onNavigateToEvent = onNavigateToEvent,
onNavigateToChatRoom = onNavigateToDirectMessage,
onNavigateToEditProfile = onNavigateToEditProfile,
nostrRepository = nostrRepository
)
}
TextNoteEvent.KIND -> {
TextNoteEventDetail(
feedListUIState.localNostrEvent,
onNavigateBack = onNavigateBack,
onNavigateToWriteAReply = onNavigateToWriteAReply,
onNavigateToNostrEvent = onNavigateToQuoteNostrEvent,
nostrRepository = nostrRepository
)
}
RepostEvent.KIND -> {
// TODO: RePost...
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(feedListUIState.localNostrEvent.nostrEvent.content)
Text(stringResource(Res.string.post_functionality_coming_soon))
when(feedListUIState.localNostrEvent.nostrEvent.kind) {
MetadataEvent.KIND -> {
MetadataEventDetail(
activeUserPublicKey = activeUserPublicKey,
localNostrEvent = feedListUIState.localNostrEvent,
onNavigateBack = onNavigateBack,
onNavigateToEvent = onNavigateToEvent,
onNavigateToChatRoom = onNavigateToDirectMessage,
onNavigateToEditProfile = onNavigateToEditProfile,
nostrRepository = nostrRepository
)
}
}
else -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
TextNoteEvent.KIND -> {
TextNoteEventDetail(
feedListUIState.localNostrEvent,
onNavigateBack = onNavigateBack,
onNavigateToWriteAReply = onNavigateToWriteAReply,
onNavigateToNostrEvent = onNavigateToQuoteNostrEvent,
nostrRepository = nostrRepository
)
}
RepostEvent.KIND -> {
// TODO: RePost...
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(feedListUIState.localNostrEvent.nostrEvent.content)
Text(feedListUIState.localNostrEvent.nostrEvent.content)
Text(stringResource(Res.string.functionality_coming_soon))
Text(stringResource(Res.string.post_functionality_coming_soon))
}
}
else -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(feedListUIState.localNostrEvent.nostrEvent.content)
Text(stringResource(Res.string.functionality_coming_soon))
}
}
}
}
}
}
}
NostrEventDetailUIState.Loading -> {
LoadingDataIndicator()
NostrEventDetailUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
nostrEventDetailViewModel.loadNostrFeed()
LaunchedEffect(true) {
nostrEventDetailViewModel.loadNostrFeed()
}
}
}
NostrEventDetailUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_your_nostr_event_please_try))
NostrEventDetailUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_your_nostr_event_please_try))
}
}
}
}

View File

@@ -49,6 +49,7 @@ import mantra.composeapp.generated.resources.search_message_functionality_will_b
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -72,104 +73,106 @@ fun SearchMemberToAddToChatRoomScreen(
)
)
when (val searchMemberToAddToChatRoomUIState = searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) {
is SearchMemberToAddToChatRoomUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = searchMemberToAddToChatRoomUIState.message,
)
}
}
is SearchMemberToAddToChatRoomUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) {
"Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}"
} else {
"Invite new member to chat"
}
Text(
text = title,
)
}
ScreenStateTransition(searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) { uiState ->
when (val searchMemberToAddToChatRoomUIState = uiState) {
is SearchMemberToAddToChatRoomUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = searchMemberToAddToChatRoomUIState.message,
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
}
is SearchMemberToAddToChatRoomUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) {
"Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}"
} else {
"Invite new member to chat"
}
Text(
text = title,
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space250),
text = stringResource(Res.string.currently_no_contacts_please_search_and_chat),
textAlign = TextAlign.Center
)
if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space250),
text = stringResource(Res.string.currently_no_contacts_please_search_and_chat),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
reverseLayout = true
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
reverseLayout = true
) {
items(
items = searchMemberToAddToChatRoomUIState.contacts,
key = { it.publicKey }
) { profile ->
Card(
onClick = {
onNavigateToRoute.invoke(
AddMemberToChatRoomConfirmationRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
profilePublicKey = profile.publicKey,
relayHint = relayHint,
items(
items = searchMemberToAddToChatRoomUIState.contacts,
key = { it.publicKey }
) { profile ->
Card(
onClick = {
onNavigateToRoute.invoke(
AddMemberToChatRoomConfirmationRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
profilePublicKey = profile.publicKey,
relayHint = relayHint,
)
)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
profile = profile
)
},
headlineContent = {
Text(
profile.humanReadableNameOrPubkey()
)
},
supportingContent = {
profile.about?.let {
Text(
text = it,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
}
)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
profile = profile
)
},
headlineContent = {
Text(
profile.humanReadableNameOrPubkey()
)
},
supportingContent = {
profile.about?.let {
Text(
text = it,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
}
)
}
}
}
@@ -177,38 +180,38 @@ fun SearchMemberToAddToChatRoomScreen(
}
}
}
}
SearchMemberToAddToChatRoomUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
SearchMemberToAddToChatRoomUIState.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.search_member_functionality),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.search_member_functionality),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.search_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.search_message_functionality_will_be_here),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -56,6 +56,7 @@ import mantra.composeapp.generated.resources.selected
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
/**
* Second step of group creation: pick who is in the group.
@@ -84,188 +85,190 @@ fun SelectChatRoomMembersScreen(
)
)
when (val selectChatRoomMembersUIState = selectChatRoomMembersViewModel.selectChatRoomMembersUIState) {
is SelectChatRoomMembersUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = selectChatRoomMembersUIState.message,
textAlign = TextAlign.Center
)
}
}
is SelectChatRoomMembersUIState.Loaded -> {
val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.add_people_to, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
ScreenStateTransition(selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { uiState ->
when (val selectChatRoomMembersUIState = uiState) {
is SelectChatRoomMembersUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
},
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomMembersViewModel.selectChatRoomType(
onNavigateToRoute = onNavigateToRoute
Text(
text = selectChatRoomMembersUIState.message,
textAlign = TextAlign.Center
)
}
}
is SelectChatRoomMembersUIState.Loaded -> {
val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.add_people_to, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
},
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomMembersViewModel.selectChatRoomType(
onNavigateToRoute = onNavigateToRoute
)
}
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next"
)
Text(
text = if (selectedCount > 0) {
stringResource(Res.string.next_with, selectedCount)
} else {
stringResource(Res.string.next)
}
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = if (selectedCount > 0) {
stringResource(Res.string.selected, selectedCount)
} else {
stringResource(Res.string.no_one_selected_yet)
},
style = MaterialTheme.typography.labelLarge
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
if (selectChatRoomMembersUIState.profiles.isEmpty()) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next"
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = if (selectedCount > 0) {
stringResource(Res.string.next_with, selectedCount)
} else {
stringResource(Res.string.next)
}
text = stringResource(Res.string.no_one_to_add_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.search_for_people_and_chat_with_them_first),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.you_can_still_carry_on_and_invite_people),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(2f)
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = if (selectedCount > 0) {
stringResource(Res.string.selected, selectedCount)
} else {
stringResource(Res.string.no_one_selected_yet)
},
style = MaterialTheme.typography.labelLarge
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
if (selectChatRoomMembersUIState.profiles.isEmpty()) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
Spacer(
modifier = Modifier.weight(1f)
)
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
items(
items = selectChatRoomMembersUIState.profiles,
key = { it.publicKey }
) { profile ->
val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey)
Text(
text = stringResource(Res.string.no_one_to_add_yet),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.search_for_people_and_chat_with_them_first),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.you_can_still_carry_on_and_invite_people),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.weight(2f)
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
items(
items = selectChatRoomMembersUIState.profiles,
key = { it.publicKey }
) { profile ->
val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey)
Card(
onClick = {
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
profile = profile
)
},
headlineContent = {
Text(
profile.humanReadableNameOrPubkey()
)
},
supportingContent = {
profile.about?.let {
Card(
onClick = {
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
profile = profile
)
},
headlineContent = {
Text(
text = it,
overflow = TextOverflow.Ellipsis,
maxLines = 1
profile.humanReadableNameOrPubkey()
)
},
supportingContent = {
profile.about?.let {
Text(
text = it,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
},
trailingContent = {
Checkbox(
checked = isSelected,
onCheckedChange = {
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
}
)
}
},
trailingContent = {
Checkbox(
checked = isSelected,
onCheckedChange = {
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
}
)
}
)
)
}
}
}
}
}
}
}
}
SelectChatRoomMembersUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
SelectChatRoomMembersUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.add_people_to, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.add_people_to, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -73,6 +73,7 @@ import mantra.composeapp.generated.resources.you_and_others
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
/**
* Last step of group creation: convenient (one admin) or robust (everyone
@@ -106,208 +107,210 @@ fun SelectChatRoomTypeScreen(
)
)
when (val selectChatRoomTypeUIState = selectChatRoomTypeViewModel.selectChatRoomTypeUIState) {
is SelectChatRoomTypeUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = selectChatRoomTypeUIState.message,
textAlign = TextAlign.Center
)
}
}
is SelectChatRoomTypeUIState.Loaded -> {
val isActionPending = selectChatRoomTypeViewModel.isActionPending.value
val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value
val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded
val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value
val adminCount = selectChatRoomTypeViewModel.adminCount
val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable
val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.how_should_be_run, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
ScreenStateTransition(selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { uiState ->
when (val selectChatRoomTypeUIState = uiState) {
is SelectChatRoomTypeUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
},
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomTypeViewModel.createChatRoom(
onNavigateToRoute = onNavigateToRoute
)
}
) {
if (isActionPending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Check,
contentDescription = "Create chat"
)
}
Text(
text = selectChatRoomTypeUIState.message,
textAlign = TextAlign.Center
)
}
}
is SelectChatRoomTypeUIState.Loaded -> {
val isActionPending = selectChatRoomTypeViewModel.isActionPending.value
val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value
val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded
val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value
val adminCount = selectChatRoomTypeViewModel.adminCount
val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable
val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) {
stringResource(Res.string.open_chat)
} else {
stringResource(Res.string.create_chat)
}
text = stringResource(Res.string.how_should_be_run, name),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = when (selectChatRoomTypeUIState.members.size) {
0 -> stringResource(Res.string.just_you_for_now)
1 -> stringResource(Res.string.you_and_1_other)
else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size)
},
style = MaterialTheme.typography.labelLarge
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (membersNotAdded.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }),
style = MaterialTheme.typography.bodyMedium
)
}
)
},
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomTypeViewModel.createChatRoom(
onNavigateToRoute = onNavigateToRoute
)
}
) {
if (isActionPending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Check,
contentDescription = "Create chat"
)
}
Text(
text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) {
stringResource(Res.string.open_chat)
} else {
stringResource(Res.string.create_chat)
}
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = when (selectChatRoomTypeUIState.members.size) {
0 -> stringResource(Res.string.just_you_for_now)
1 -> stringResource(Res.string.you_and_1_other)
else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size)
},
style = MaterialTheme.typography.labelLarge
)
}
)
}
if (keyCeremonyNotStarted) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name),
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(
text = stringResource(Res.string.this_decides_who_can_change_the_group_later),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space125)
)
ChatRoomTypeCard(
icon = Icons.Default.Bolt,
title = "Convenient",
summary = "You are the only admin.",
detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.",
footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.",
isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT)
}
)
ChatRoomTypeCard(
icon = Icons.Default.Groups,
title = "Robust",
summary = "Every member is an admin.",
detail = if (isRobustAvailable) {
"All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect."
} else {
"Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect."
},
// Says what tapping create actually does, because it is the one
// thing here that asks something of everybody else: the group's
// first act is a ceremony each member has to approve their way
// through before there is a key.
footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.",
isSelected = isRobustSelected,
isEnabled = isRobustAvailable,
disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
// Ask for the quorum only once robust is the choice — before that
// there is no decision to make.
if (isRobustSelected) {
QuorumPicker(
quorum = selectChatRoomTypeViewModel.quorum.value,
adminCount = adminCount,
quorumRange = selectChatRoomTypeViewModel.quorumRange,
explanation = selectChatRoomTypeViewModel.quorumExplanation(),
onQuorumChange = selectChatRoomTypeViewModel::setQuorum
)
if (membersNotAdded.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }),
style = MaterialTheme.typography.bodyMedium
)
}
}
if (keyCeremonyNotStarted) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name),
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(
text = stringResource(Res.string.this_decides_who_can_change_the_group_later),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space125)
)
ChatRoomTypeCard(
icon = Icons.Default.Bolt,
title = "Convenient",
summary = "You are the only admin.",
detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.",
footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.",
isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT)
}
)
ChatRoomTypeCard(
icon = Icons.Default.Groups,
title = "Robust",
summary = "Every member is an admin.",
detail = if (isRobustAvailable) {
"All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect."
} else {
"Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect."
},
// Says what tapping create actually does, because it is the one
// thing here that asks something of everybody else: the group's
// first act is a ceremony each member has to approve their way
// through before there is a key.
footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.",
isSelected = isRobustSelected,
isEnabled = isRobustAvailable,
disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST)
}
) {
// Ask for the quorum only once robust is the choice — before that
// there is no decision to make.
if (isRobustSelected) {
QuorumPicker(
quorum = selectChatRoomTypeViewModel.quorum.value,
adminCount = adminCount,
quorumRange = selectChatRoomTypeViewModel.quorumRange,
explanation = selectChatRoomTypeViewModel.quorumExplanation(),
onQuorumChange = selectChatRoomTypeViewModel::setQuorum
)
}
}
}
}
}
}
SelectChatRoomTypeUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
SelectChatRoomTypeUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
MaterialTheme.spacing.space250
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.how_should_be_run, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.how_should_be_run, name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
}

View File

@@ -55,6 +55,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -76,149 +77,151 @@ fun ShareProfileScreen(
),
)
when(val shareProfileUIState = shareProfileViewModel.shareProfileUIState) {
ShareProfileUIState.Error -> {
ErrorState()
}
is ShareProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.profile)
)
},
navigationIcon = {
IconButton(
onClick = {
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back"
ScreenStateTransition(shareProfileViewModel.shareProfileUIState) { uiState ->
when (val shareProfileUIState = uiState) {
ShareProfileUIState.Error -> {
ErrorState()
}
is ShareProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.profile)
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
ProfileAvatar(
profile = shareProfileUIState.localNostrEvent.profile,
publicKey = activeUserPublicKey
)
Column {
shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey ->
Text(
text = humanReadableNameOrPubkey,
style = MaterialTheme.typography.titleLarge
)
}
shareProfileUIState.localNostrEvent.profile?.about?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
item {
Row(
modifier = Modifier.padding(MaterialTheme.spacing.space500)
) {
QRCodeView(
activeUserPublicKey.hexToNpubHrp()
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250)
) {
val clipboardManager = LocalClipboardManager.current
TextButton(
},
navigationIcon = {
IconButton(
onClick = {
clipboardManager.setText(
buildAnnotatedString {
append(activeUserPublicKey.hexToNpubHrp())
}
)
onNavigateBack.invoke()
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy npub"
Icons.Default.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
ProfileAvatar(
profile = shareProfileUIState.localNostrEvent.profile,
publicKey = activeUserPublicKey
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
text = activeUserPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.labelSmall
Column {
shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey ->
Text(
text = humanReadableNameOrPubkey,
style = MaterialTheme.typography.titleLarge
)
}
shareProfileUIState.localNostrEvent.profile?.about?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
item {
Row(
modifier = Modifier.padding(MaterialTheme.spacing.space500)
) {
QRCodeView(
activeUserPublicKey.hexToNpubHrp()
)
}
}
}
}
item {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250)
) {
val clipboardManager = LocalClipboardManager.current
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
shareProfileViewModel.rebroadcastProfile(
localNostrEvent = shareProfileUIState.localNostrEvent
TextButton(
onClick = {
clipboardManager.setText(
buildAnnotatedString {
append(activeUserPublicKey.hexToNpubHrp())
}
)
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy npub"
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(
text = activeUserPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
shareProfileViewModel.rebroadcastProfile(
localNostrEvent = shareProfileUIState.localNostrEvent
)
}
) {
Text(
stringResource(Res.string.re_broadcast)
)
}
) {
Text(
stringResource(Res.string.re_broadcast)
)
}
}
}
}
}
ShareProfileUIState.Loading -> {
LoadingDataIndicator()
ShareProfileUIState.Loading -> {
LoadingDataIndicator()
LaunchedEffect(true) {
shareProfileViewModel.loadNostrFeed()
LaunchedEffect(true) {
shareProfileViewModel.loadNostrFeed()
}
}
}
ShareProfileUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
ShareProfileUIState.NotFound -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please))
}
}
}
}

View File

@@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.translation
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -96,163 +97,165 @@ fun TranslateChunkScreen(
)
)
when (val translateChunkUIState = translateChunkViewModel.translateChunkUIState) {
is TranslateChunkUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translateChunkUIState.message)
ScreenStateTransition(translateChunkViewModel.translateChunkUIState) { uiState ->
when (val translateChunkUIState = uiState) {
is TranslateChunkUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translateChunkUIState.message)
}
}
}
is TranslateChunkUIState.Loaded -> {
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
is TranslateChunkUIState.Loaded -> {
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.translate_chunk)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (translateChunkUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (translateChunkUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (translateChunkUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton
translateChunkViewModel.proposeTranslation(
localChatRoom = translateChunkUIState.localChatRoom,
originalChunk = translateChunkUIState.originalChunk,
translationField = translationFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the chapter table. Nothing has been
// translated yet -- the chunk is
// translated when enough members sign --
// so a table still showing it untranslated
// would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed translation")
)
}
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.translate_chunk)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
},
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (translateChunkUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (translateChunkUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (translateChunkUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton
translateChunkViewModel.proposeTranslation(
localChatRoom = translateChunkUIState.localChatRoom,
originalChunk = translateChunkUIState.originalChunk,
translationField = translationFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the chapter table. Nothing has been
// translated yet -- the chunk is
// translated when enough members sign --
// so a table still showing it untranslated
// would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed translation")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!translateChunkUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"translation. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!translateChunkUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"translation. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
text = stringResource(Res.string.original),
style = MaterialTheme.typography.labelMedium
)
Card {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.containerPadding),
text = translateChunkUIState.originalChunk.text,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = stringResource(Res.string.translation),
style = MaterialTheme.typography.labelMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = translationFieldState,
label = { Text(stringResource(Res.string.translated_text)) },
placeholder = { Text(stringResource(Res.string.enter_the_translation_for_this_chunk)) },
)
}
Text(
text = stringResource(Res.string.original),
style = MaterialTheme.typography.labelMedium
)
Card {
Text(
modifier = Modifier.padding(MaterialTheme.spacing.containerPadding),
text = translateChunkUIState.originalChunk.text,
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = stringResource(Res.string.translation),
style = MaterialTheme.typography.labelMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = translationFieldState,
label = { Text(stringResource(Res.string.translated_text)) },
placeholder = { Text(stringResource(Res.string.enter_the_translation_for_this_chunk)) },
)
}
}
}
TranslateChunkUIState.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.translate_chunk),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
TranslateChunkUIState.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.translate_chunk),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -60,6 +60,7 @@ import mantra.composeapp.generated.resources.chunks_translated
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -88,188 +89,190 @@ fun TranslationArtifactVersionDetailScreen(
)
)
when (val translationDetailUIState = translationDetailViewModel.translationDetailUIState) {
is TranslationArtifactVersionDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationDetailUIState.message)
}
}
is TranslationArtifactVersionDetailUIState.Loaded -> {
val translation = translationDetailUIState.translation
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(translation.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
ScreenStateTransition(translationDetailViewModel.translationDetailUIState) { uiState ->
when (val translationDetailUIState = uiState) {
is TranslationArtifactVersionDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Chapters the source version has and this translation
// does not. Above the list rather than after it: a chapter
// that is not here is invisible from a list of the ones
// that are.
val localChatRoom = translationDetailUIState.localChatRoom
val missingChapters = translationDetailUIState.missingChapters
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationDetailUIState.message)
}
}
if (missingChapters.isNotEmpty()) {
item {
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Chapters missing from this translation"
)
},
headlineContent = {
Text(
"${missingChapters.size} " +
(if (missingChapters.size == 1) "chapter is" else "chapters are") +
" not in this translation yet"
)
},
supportingContent = {
Text(
if (!translationDetailUIState.canSign) {
stringResource(Res.string.this_group_has_no_shared_key_so_it_cannot)
} else {
"They were signed into the artifact after this " +
"translation. Ask the group to add them so they " +
"can be translated."
}
)
},
trailingContent = {
TextButton(
enabled = localChatRoom != null &&
translationDetailUIState.canSign &&
!translationDetailViewModel.isActionPending.value,
onClick = {
if (localChatRoom == null) return@TextButton
is TranslationArtifactVersionDetailUIState.Loaded -> {
val translation = translationDetailUIState.translation
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(translation.name) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
// Chapters the source version has and this translation
// does not. Above the list rather than after it: a chapter
// that is not here is invisible from a list of the ones
// that are.
val localChatRoom = translationDetailUIState.localChatRoom
val missingChapters = translationDetailUIState.missingChapters
translationDetailViewModel.catchUpMissingChapters(
localChatRoom = localChatRoom,
missingChapters = missingChapters,
onSuccess = { sessionId ->
onNavigateToRoute.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
if (missingChapters.isNotEmpty()) {
item {
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Chapters missing from this translation"
)
},
headlineContent = {
Text(
"${missingChapters.size} " +
(if (missingChapters.size == 1) "chapter is" else "chapters are") +
" not in this translation yet"
)
},
supportingContent = {
Text(
if (!translationDetailUIState.canSign) {
stringResource(Res.string.this_group_has_no_shared_key_so_it_cannot)
} else {
"They were signed into the artifact after this " +
"translation. Ask the group to add them so they " +
"can be translated."
}
)
},
trailingContent = {
TextButton(
enabled = localChatRoom != null &&
translationDetailUIState.canSign &&
!translationDetailViewModel.isActionPending.value,
onClick = {
if (localChatRoom == null) return@TextButton
translationDetailViewModel.catchUpMissingChapters(
localChatRoom = localChatRoom,
missingChapters = missingChapters,
onSuccess = { sessionId ->
onNavigateToRoute.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
)
},
onFailure = {}
)
},
onFailure = {}
)
}
) {
Text(stringResource(Res.string.propose))
}
) {
Text(stringResource(Res.string.propose))
}
)
}
}
item { HorizontalDivider() }
}
// Chapters with translation progress
item {
Text(
text = stringResource(Res.string.chapters),
style = MaterialTheme.typography.labelMedium
)
}
if (translationDetailUIState.chapters.isEmpty()) {
item { Text(stringResource(Res.string.no_chapters)) }
} else {
items(
items = translationDetailUIState.chapters,
key = { progress -> progress.chapter.id }
) { progress ->
Card(
onClick = {
onNavigateToRoute.invoke(
TranslationChapterRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = progress.chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
)
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
)
},
headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) },
supportingContent = {
Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks))
}
)
}
}
}
item { HorizontalDivider() }
}
// Chapters with translation progress
item {
Text(
text = stringResource(Res.string.chapters),
style = MaterialTheme.typography.labelMedium
)
}
if (translationDetailUIState.chapters.isEmpty()) {
item { Text(stringResource(Res.string.no_chapters)) }
} else {
items(
items = translationDetailUIState.chapters,
key = { progress -> progress.chapter.id }
) { progress ->
Card(
onClick = {
onNavigateToRoute.invoke(
TranslationChapterRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = progress.chapter.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Article,
contentDescription = "Chapter"
)
},
headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) },
supportingContent = {
Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks))
}
)
}
// Details
item {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.labelMedium
)
}
}
item { HorizontalDivider() }
// Details
item {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
DetailRow(label = "Name", value = translation.name)
DetailRow(label = "Dialect", value = translation.dialectId)
DetailRow(label = "Visibility", value = translation.visibility)
DetailRow(label = "License", value = translation.license)
DetailRow(label = "Source version", value = translation.artifactVersionId)
DetailRow(label = "Author", value = translation.publicKey)
DetailRow(label = "Created", value = translation.createdAt.toString())
item {
Card {
DetailRow(label = "Name", value = translation.name)
DetailRow(label = "Dialect", value = translation.dialectId)
DetailRow(label = "Visibility", value = translation.visibility)
DetailRow(label = "License", value = translation.license)
DetailRow(label = "Source version", value = translation.artifactVersionId)
DetailRow(label = "Author", value = translation.publicKey)
DetailRow(label = "Created", value = translation.createdAt.toString())
}
}
}
}
}
}
TranslationArtifactVersionDetailUIState.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.translation_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
TranslationArtifactVersionDetailUIState.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.translation_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.this_chapter_has_no_chunks
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -79,105 +80,107 @@ fun TranslationChapterScreen(
)
)
when (val translationChapterUIState = translationChapterViewModel.translationChapterUIState) {
is TranslationChapterUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationChapterUIState.message)
}
}
is TranslationChapterUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chapter_translation)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
ScreenStateTransition(translationChapterViewModel.translationChapterUIState) { uiState ->
when (val translationChapterUIState = uiState) {
is TranslationChapterUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header row: dialect names.
item {
TableRow(
left = {
Text(
text = translationChapterUIState.originalDialectName,
fontWeight = FontWeight.Bold
)
},
right = {
Text(
text = translationChapterUIState.translationDialectName,
fontWeight = FontWeight.Bold
)
},
)
HorizontalDivider()
}
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationChapterUIState.message)
}
}
if (translationChapterUIState.rows.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
contentAlignment = Alignment.Center
) {
Text(stringResource(Res.string.this_chapter_has_no_chunks))
}
}
} else {
items(
items = translationChapterUIState.rows,
key = { pair -> pair.originalChunk.id }
) { pair ->
ChunkTranslationRow(
pair = pair,
onClick = {
onNavigateToRoute.invoke(
TranslateChunkRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chunkId = pair.originalChunk.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
is TranslationChapterUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chapter_translation)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
// Header row: dialect names.
item {
TableRow(
left = {
Text(
text = translationChapterUIState.originalDialectName,
fontWeight = FontWeight.Bold
)
},
right = {
Text(
text = translationChapterUIState.translationDialectName,
fontWeight = FontWeight.Bold
)
},
)
HorizontalDivider()
}
if (translationChapterUIState.rows.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
contentAlignment = Alignment.Center
) {
Text(stringResource(Res.string.this_chapter_has_no_chunks))
}
}
} else {
items(
items = translationChapterUIState.rows,
key = { pair -> pair.originalChunk.id }
) { pair ->
ChunkTranslationRow(
pair = pair,
onClick = {
onNavigateToRoute.invoke(
TranslateChunkRoute(
activeUserPublicKey = activeUserPublicKey,
translationChapterId = translationChapterId,
chunkId = pair.originalChunk.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
)
HorizontalDivider()
}
}
}
}
}
}
TranslationChapterUIState.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.chapter_translation),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
TranslationChapterUIState.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.chapter_translation),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}

View File

@@ -1,5 +1,11 @@
package press.mantra.compose.ui.composable.widgets
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -7,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,6 +27,7 @@ import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.try_again
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.ui.theme.reducedMotion
import press.mantra.compose.ui.theme.spacing
/**
@@ -125,3 +133,68 @@ private fun StateMessage(
action?.invoke()
}
}
/**
* Fades between a screen's states instead of cutting.
*
* Every screen in this app is a `when` over a UI state -- loading, error, empty, loaded --
* and every one of those transitions was an unannounced cut: the spinner is there in one
* frame and the content is there in the next, with nothing saying they are the same screen
* answering the same question.
*
* M3's fade-through, which is the transition for content that replaces other content
* without being spatially related to it: the outgoing state fades out, the incoming one
* fades in and grows the last few percent into place. `SizeTransform` is off, so a tall
* loaded state does not stretch a short spinner on its way in.
*
* **The content key is the state's class, not the state.** Without it a screen would
* re-run the whole transition every time its loaded data changed -- a message arriving, a
* list growing -- which is a fade over content that did not change state at all. With it,
* the animation runs when the state does and the data flows through untouched.
*
* **`AnimatedContent` is a layout node**, so it must wrap a `when` that is a composable's
* whole body. Where the `when` sits inside a `Column` whose branches use
* `Modifier.weight`, wrapping it would take the branches out of `ColumnScope`; those
* screens are left alone and the reason is here rather than repeated at each of them.
*
* @param state the screen's current UI state.
* @param label named for the animation inspector; the screen's own name is the useful one.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun <T : Any> ScreenStateTransition(
state: T,
modifier: Modifier = Modifier,
label: String = "screen state",
content: @Composable (T) -> Unit,
) {
val reduced = MaterialTheme.reducedMotion
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
AnimatedContent(
targetState = state,
modifier = modifier,
contentKey = { it::class },
transitionSpec = {
if (reduced) {
// Still a transition, still not a cut -- what goes is the movement. The
// scale is the only spatial part of a fade-through.
fadeIn(effects) togetherWith fadeOut(effects) using SizeTransform(clip = false)
} else {
(fadeIn(effects) + scaleIn(spatial, initialScale = EnteringScale))
.togetherWith(fadeOut(effects))
.using(SizeTransform(clip = false))
}
},
label = label,
) { targetState -> content(targetState) }
}
/**
* How small the arriving state starts.
*
* M3's fade-through grows the incoming content from 92%. Any less reads as a zoom, which
* says the two states are the same thing seen closer rather than one replacing the other.
*/
private const val EnteringScale = 0.92f

View File

@@ -0,0 +1,86 @@
package press.mantra.compose.ui.composable.widgets
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runDesktopComposeUiTest
import press.mantra.compose.ui.theme.TorchTheme
import kotlin.test.Test
/** Stand-ins for a screen's UI state: two classes, and data that changes within one. */
private sealed interface Fixture {
data object Loading : Fixture
data class Loaded(val text: String) : Fixture
}
/**
* That a screen's state change is a transition rather than a cut, and that ordinary data
* changes are not.
*
* Both halves matter and the second is the one that is easy to get wrong. `AnimatedContent`
* keyed on the state *value* would re-run the whole fade every time a loaded list grew by
* one row -- a screen that flickers whenever a message arrives -- and it would look
* entirely correct in any screenshot. Keying on the state's class is what separates "the
* screen is now answering a different question" from "the answer got longer".
*
* The clock is held still so a frame in the middle of a transition can be inspected. That
* frame is the whole test: during a crossfade both states are composed, and during a cut
* only ever one is.
*/
@OptIn(ExperimentalTestApi::class)
class ScreenStateTransitionJvmTest {
@Test
fun `both states are on screen while one replaces the other`() =
runDesktopComposeUiTest(400, 600) {
var state by mutableStateOf<Fixture>(Fixture.Loading)
mainClock.autoAdvance = false
setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } }
mainClock.advanceTimeBy(16)
onNodeWithText("loading").assertIsDisplayed()
runOnUiThread { state = Fixture.Loaded("first") }
mainClock.advanceTimeBy(48)
// The frame that distinguishes a transition from a cut.
onNodeWithText("loading").assertExists()
onNodeWithText("first").assertExists()
mainClock.autoAdvance = true
waitForIdle()
onNodeWithText("loading").assertDoesNotExist()
onNodeWithText("first").assertIsDisplayed()
}
@Test
fun `data changing inside one state does not animate`() = runDesktopComposeUiTest(400, 600) {
var state by mutableStateOf<Fixture>(Fixture.Loaded("first"))
mainClock.autoAdvance = false
setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } }
mainClock.advanceTimeBy(16)
onNodeWithText("first").assertIsDisplayed()
runOnUiThread { state = Fixture.Loaded("second") }
mainClock.advanceTimeBy(48)
// Same class, so `contentKey` is unchanged and nothing crossfades: the old text is
// gone at once rather than lingering through a fade nobody asked for.
onNodeWithText("first").assertDoesNotExist()
onNodeWithText("second").assertIsDisplayed()
mainClock.autoAdvance = true
waitForIdle()
}
@androidx.compose.runtime.Composable
private fun Render(state: Fixture) = when (state) {
Fixture.Loading -> Text("loading")
is Fixture.Loaded -> Text(state.text)
}
}