feat: replace Messages FAB speed-dial with a full-screen conversation chooser

The Messages "+" button used to fan out four cryptic one-word FABs
(Private / Public / Group / Find groups). The labels didn't explain what
each protocol does, and two conversation types (Disappearing Chat and
Concord communities) had no entry point at all.

Replace the speed-dial with a single "+" that opens a new full-screen
"Start a conversation" chooser. It groups the six conversation types into
three intent buckets — Direct & private, Encrypted groups, Public & open —
and presents each as a card with an icon, a one-line tagline, a "Best for"
hint, and short pros/cons lists, so users can pick the right message
protocol for what they're building. Each card routes to that type's
existing creation (or browse) flow.

- New Route.NewConversation + NewConversationScreen
- ChannelFabColumn simplified to a single button opening the chooser
- Adds the previously-unreachable Disappearing Chat and Concord entries
This commit is contained in:
Claude
2026-07-15 14:31:39 +00:00
parent dfde5336f1
commit ed0df81ccd
5 changed files with 445 additions and 124 deletions
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewConversationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
@@ -744,6 +745,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottom<Route.NewConversation> { NewConversationScreen(nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
@@ -748,6 +748,11 @@ sealed class Route {
val attachment: String? = null,
) : Route()
// Full-screen chooser opened from the Messages FAB: explains each conversation type
// (DM, Marmot group, Concord, public chat, relay group, disappearing chat) with its
// pros/cons and routes to that type's creation flow.
@Serializable object NewConversation : Route()
@Serializable data class ShareToDM(
val message: String? = null,
val attachment: String? = null,
@@ -20,148 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewGroupDM
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
/**
* The Messages "+" button. Instead of a speed-dial that fanned out several cryptic one-word FABs
* (Private / Public / Group / Find groups), a single button opens the full-screen
* [NewConversationScreen] chooser, which explains every conversation type and its trade-offs before
* the user commits to one.
*/
@Composable
fun ChannelFabColumn(nav: INav) {
var isOpen by remember { mutableStateOf(false) }
Column {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
nav.nav(NewGroupDM())
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.ChannelMetadataEdit())
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.CreateMarmotGroup)
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_group),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.RelayGroupBrowse)
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.relay_group_browse_title),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
val rotationDegree by animateFloatAsState(
targetValue = if (isOpen) 45f else 0f,
FloatingActionButton(
onClick = { nav.nav(Route.NewConversation) },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.messages_create_public_private_chat_description),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
FloatingActionButton(
onClick = { isOpen = !isOpen },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.messages_create_public_private_chat_description),
modifier =
Modifier.size(26.dp).graphicsLayer {
rotationZ = rotationDegree
},
tint = Color.White,
)
}
}
}
@@ -0,0 +1,362 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* One selectable conversation type on the [NewConversationScreen] chooser: an icon, a name, a
* one-line tagline, a "best for" hint, and short pros/cons lists. Tapping the card routes to that
* type's existing creation (or browse) flow.
*/
private class ConversationType(
val icon: MaterialSymbol,
@StringRes val title: Int,
@StringRes val tagline: Int,
@StringRes val bestFor: Int,
val pros: List<Int>,
val cons: List<Int>,
val route: Route,
)
private class ConversationSection(
@StringRes val header: Int,
val types: List<ConversationType>,
)
// Grouped by intent so the six protocols read as three simple buckets instead of a flat wall of
// options: talk privately, run an encrypted group, or open something to the public.
private val conversationSections =
listOf(
ConversationSection(
header = R.string.new_conversation_section_direct,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Mail,
title = R.string.new_conversation_dm_title,
tagline = R.string.new_conversation_dm_tagline,
bestFor = R.string.new_conversation_dm_best,
pros =
listOf(
R.string.new_conversation_dm_pro_1,
R.string.new_conversation_dm_pro_2,
R.string.new_conversation_dm_pro_3,
),
cons =
listOf(
R.string.new_conversation_dm_con_1,
R.string.new_conversation_dm_con_2,
),
route = Route.NewGroupDM(),
),
),
),
ConversationSection(
header = R.string.new_conversation_section_encrypted,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Lock,
title = R.string.new_conversation_marmot_title,
tagline = R.string.new_conversation_marmot_tagline,
bestFor = R.string.new_conversation_marmot_best,
pros =
listOf(
R.string.new_conversation_marmot_pro_1,
R.string.new_conversation_marmot_pro_2,
R.string.new_conversation_marmot_pro_3,
),
cons =
listOf(
R.string.new_conversation_marmot_con_1,
R.string.new_conversation_marmot_con_2,
),
route = Route.CreateMarmotGroup,
),
ConversationType(
icon = MaterialSymbols.Groups,
title = R.string.new_conversation_concord_title,
tagline = R.string.new_conversation_concord_tagline,
bestFor = R.string.new_conversation_concord_best,
pros =
listOf(
R.string.new_conversation_concord_pro_1,
R.string.new_conversation_concord_pro_2,
R.string.new_conversation_concord_pro_3,
),
cons =
listOf(
R.string.new_conversation_concord_con_1,
R.string.new_conversation_concord_con_2,
),
route = Route.ConcordCreate,
),
),
),
ConversationSection(
header = R.string.new_conversation_section_public,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Public,
title = R.string.new_conversation_public_chat_title,
tagline = R.string.new_conversation_public_chat_tagline,
bestFor = R.string.new_conversation_public_chat_best,
pros =
listOf(
R.string.new_conversation_public_chat_pro_1,
R.string.new_conversation_public_chat_pro_2,
),
cons =
listOf(
R.string.new_conversation_public_chat_con_1,
R.string.new_conversation_public_chat_con_2,
),
route = Route.ChannelMetadataEdit(),
),
ConversationType(
icon = MaterialSymbols.Dns,
title = R.string.new_conversation_relay_group_title,
tagline = R.string.new_conversation_relay_group_tagline,
bestFor = R.string.new_conversation_relay_group_best,
pros =
listOf(
R.string.new_conversation_relay_group_pro_1,
R.string.new_conversation_relay_group_pro_2,
),
cons =
listOf(
R.string.new_conversation_relay_group_con_1,
R.string.new_conversation_relay_group_con_2,
),
route = Route.RelayGroupBrowse,
),
ConversationType(
icon = MaterialSymbols.Timer,
title = R.string.new_conversation_ephemeral_title,
tagline = R.string.new_conversation_ephemeral_tagline,
bestFor = R.string.new_conversation_ephemeral_best,
pros =
listOf(
R.string.new_conversation_ephemeral_pro_1,
R.string.new_conversation_ephemeral_pro_2,
),
cons =
listOf(
R.string.new_conversation_ephemeral_con_1,
R.string.new_conversation_ephemeral_con_2,
),
route = Route.NewEphemeralChat,
),
),
),
)
@Composable
fun NewConversationScreen(nav: INav) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(R.string.new_conversation_title), nav)
},
) { pad ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding =
PaddingValues(
start = 12.dp,
end = 12.dp,
top = pad.calculateTopPadding() + 4.dp,
bottom = pad.calculateBottomPadding() + 16.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text(
text = stringRes(R.string.new_conversation_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
)
}
conversationSections.forEach { section ->
item {
Text(
text = stringRes(section.header),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, top = 6.dp),
)
}
section.types.forEach { type ->
item(key = type.title) {
ConversationTypeCard(type) { nav.nav(type.route) }
}
}
}
}
}
}
@Composable
private fun ConversationTypeCard(
type: ConversationType,
onClick: () -> Unit,
) {
ElevatedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
symbol = type.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(Modifier.size(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = stringRes(type.title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(type.tagline),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Icon(
symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.grayText,
modifier = Modifier.size(22.dp),
)
}
Spacer(modifier = DoubleVertSpacer)
Row {
Text(
text = "${stringRes(R.string.new_conversation_best_for)}: ",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(type.bestFor),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Spacer(modifier = StdVertSpacer)
type.pros.forEach { ProConRow(it, isPro = true) }
type.cons.forEach { ProConRow(it, isPro = false) }
}
}
}
@Composable
private fun ProConRow(
@StringRes text: Int,
isPro: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 1.dp),
) {
Icon(
symbol = if (isPro) MaterialSymbols.Check else MaterialSymbols.Close,
contentDescription = null,
tint = if (isPro) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(8.dp))
Text(
text = stringRes(text),
style = MaterialTheme.typography.bodySmall,
)
}
}
@Preview
@Composable
private fun NewConversationScreenPreview() {
ThemeComparisonColumn {
NewConversationScreen(nav = EmptyNav())
}
}
+59
View File
@@ -2049,6 +2049,65 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Group</string>
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB) -->
<string name="new_conversation_title">Start a conversation</string>
<string name="new_conversation_intro">Pick the kind of conversation that fits what you need. They differ in who can join, how private they are, and where the messages live.</string>
<string name="new_conversation_best_for">Best for</string>
<string name="new_conversation_section_direct">Direct &amp; private</string>
<string name="new_conversation_section_encrypted">Encrypted groups</string>
<string name="new_conversation_section_public">Public &amp; open</string>
<string name="new_conversation_dm_title">Private Message</string>
<string name="new_conversation_dm_tagline">Encrypted direct messages with one person or a small group.</string>
<string name="new_conversation_dm_best">Talking privately with people you already know.</string>
<string name="new_conversation_dm_pro_1">End-to-end encrypted</string>
<string name="new_conversation_dm_pro_2">Hides who you\'re talking to</string>
<string name="new_conversation_dm_pro_3">Works across all your relays</string>
<string name="new_conversation_dm_con_1">Best for small groups</string>
<string name="new_conversation_dm_con_2">No shared admins or moderation</string>
<string name="new_conversation_marmot_title">Marmot Group</string>
<string name="new_conversation_marmot_tagline">Strongly encrypted group chat built on the MLS standard.</string>
<string name="new_conversation_marmot_best">A private team or friend group that needs the strongest encryption.</string>
<string name="new_conversation_marmot_pro_1">Forward-secret group encryption</string>
<string name="new_conversation_marmot_pro_2">Relays can\'t read the messages</string>
<string name="new_conversation_marmot_pro_3">Controlled membership</string>
<string name="new_conversation_marmot_con_1">Newer, still maturing</string>
<string name="new_conversation_marmot_con_2">Everyone needs a Marmot-capable app</string>
<string name="new_conversation_concord_title">Concord Community</string>
<string name="new_conversation_concord_tagline">An encrypted community with multiple channels, like a private server.</string>
<string name="new_conversation_concord_best">Running a community or team with topic channels and roles.</string>
<string name="new_conversation_concord_pro_1">Many channels and roles</string>
<string name="new_conversation_concord_pro_2">Encrypted, with invite links</string>
<string name="new_conversation_concord_pro_3">Scales to larger groups</string>
<string name="new_conversation_concord_con_1">More to set up</string>
<string name="new_conversation_concord_con_2">Members need a Concord-capable app</string>
<string name="new_conversation_public_chat_title">Public Chat</string>
<string name="new_conversation_public_chat_tagline">An open channel anyone can find, read, and join.</string>
<string name="new_conversation_public_chat_best">A public topic room open to everyone.</string>
<string name="new_conversation_public_chat_pro_1">Anyone can join</string>
<string name="new_conversation_public_chat_pro_2">Discoverable and simple</string>
<string name="new_conversation_public_chat_con_1">Not private — messages are public</string>
<string name="new_conversation_public_chat_con_2">Limited moderation</string>
<string name="new_conversation_relay_group_title">Relay Group</string>
<string name="new_conversation_relay_group_tagline">A group hosted and moderated by a specific relay.</string>
<string name="new_conversation_relay_group_best">A managed group with admins, roles, and moderation.</string>
<string name="new_conversation_relay_group_pro_1">Real moderation and roles</string>
<string name="new_conversation_relay_group_pro_2">Can be open or invite-only</string>
<string name="new_conversation_relay_group_con_1">Lives on a single relay</string>
<string name="new_conversation_relay_group_con_2">That relay can see the group</string>
<string name="new_conversation_ephemeral_title">Disappearing Chat</string>
<string name="new_conversation_ephemeral_tagline">A temporary room tied to a relay — messages aren\'t stored.</string>
<string name="new_conversation_ephemeral_best">Quick, in-the-moment chats you don\'t want kept.</string>
<string name="new_conversation_ephemeral_pro_1">Nothing is saved</string>
<string name="new_conversation_ephemeral_pro_2">Lightweight and instant</string>
<string name="new_conversation_ephemeral_con_1">History disappears</string>
<string name="new_conversation_ephemeral_con_2">Only people on that relay see it</string>
<string name="relay_groups_title">Relay Groups</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>