feat: let a group define the dialects it translates into

Dialects existed but had nowhere to come from. The only way to create
one was the "New dialect" branch buried inside the add-artifact form,
which meant a dialect could only be born as a side effect of adding the
first artifact written in it. A group that wanted to line up the
languages it works in before any source material arrived had no way to
say so, and a dialect created that way was invisible afterwards -- there
was no screen anywhere that listed what the group had defined.

Give the group detail screen a Dialects section between Library and
Projects: the dialects defined in this room, each showing its name over
"<language> · <country>", and an Add Dialect button. The cards do not
navigate -- there is no dialect detail screen to open, and a card that
goes nowhere is worse than one that plainly does not.

The add screen is the add-artifact form with the artifact half removed:
the same chat-room title bar, the same bottom bar with an extended FAB,
the same three fields (name, country, language) styled the same way.
On success it returns to the group with popUpTo<ChatRoomDetailRoute>
{inclusive = true}, replacing the stale detail screen beneath it so the
new dialect is actually in the list when you land -- these lists load
once, in the view model's initiate().

One deliberate difference from AddArtifactViewModel: it wraps its whole
body in `localChatRoom.chatRoom.toMlsGroup()?.let { ... }` and so does
nothing at all, silently, in a NIP-17 room. Nothing under
MantraRepository.addDialect needs an MLS group, so the gate is left out
rather than copied into a new screen as a button that does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 20:20:07 +02:00
parent 907dba3c3b
commit 6ff9fd2d38
8 changed files with 569 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddDialectViewModel
import press.mantra.compose.ui.view.state.AddDialectUIState
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AddDialectScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
val addDialectViewModel: AddDialectViewModel = viewModel(
factory = AddDialectViewModel.factory(
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey,
mantraRepository = mantraRepository
)
)
when (val addDialectUIState = addDialectViewModel.addDialectUIState) {
is AddDialectUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = addDialectUIState.message,
)
}
}
is AddDialectUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
Scaffold(
topBar = {
TopAppBar(
title = {
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = {
// Back to the group, reloaded so the new
// dialect shows up in the list.
onNavigateToRouteAndPopUpInclusive.invoke(
ChatRoomDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed Dialect")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Add dialect"
)
Text("Add Dialect")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Add a dialect the group can translate into")
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 = "Dialect Name"
)
},
placeholder = {
Text(
text = "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 = "Country"
)
},
placeholder = {
Text(
text = "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 = "Language"
)
},
placeholder = {
Text(
text = "eg. st"
)
},
)
}
}
}
}
AddDialectUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
20.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Add Dialect",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
if (initialAddDialectUIState == AddDialectUIState.Loading) {
addDialectViewModel.initiateAddDialect()
}
}
}
@Preview
@Composable
private fun AddDialectScreenPreview() {
TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
AddDialectScreen(
activeUserPublicKey = "",
chatRoomId = "publicKey",
relayHint = null,
initialAddDialectUIState = AddDialectUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "",
userPublicKey = "",
subject = "Message title",
description = "See something. Say somethin",
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
)
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)
}
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Schema
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Unsubscribe
import androidx.compose.material.icons.filled.WaterfallChart
import androidx.compose.material3.ButtonDefaults
@@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@@ -217,6 +219,72 @@ fun ChatRoomDetailScreen(
HorizontalDivider()
}
item {
// Dialects
Text(
text = "Dialects",
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.dialects.isEmpty()) {
item {
Text("No dialects have been defined in this group.")
}
} 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
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add new dialect"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Add Dialect")
}
}
item {
HorizontalDivider()
}
// TODO: Add projects...
item {

View File

@@ -103,6 +103,7 @@ import kotlinx.coroutines.launch
import press.mantra.compose.database.repository.DatabaseMantraRepository
import press.mantra.compose.ui.composable.AddArtifactScreen
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@@ -112,6 +113,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddDialectScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
@@ -814,6 +816,34 @@ fun MantraNavHost(
}
)
}
composable<AddDialectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDialectRoute>()
AddDialectScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
mantraRepository = databaseMantraRepository,
onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
// Replace both this add screen and the stale chat room detail
// beneath it so we land on a freshly-loaded detail screen.
navController.navigate(
route = chatRoomDetailRoute
) {
popUpTo<ChatRoomDetailRoute> {
inclusive = true
}
}
},
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
}
)
}
composable<ArtifactDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ArtifactDetailRoute>()

View File

@@ -0,0 +1,10 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class AddDialectRoute(
val activeUserPublicKey: String,
val chatRoomId: String, // TODO: have this as a publicKey
val relayHint: String?
): Route()

View File

@@ -0,0 +1,134 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.AddDialectUIState
class AddDialectViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialAddDialectUIState: AddDialectUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val mantraRepository: MantraRepository,
): ViewModel() {
var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun initiateAddDialect() {
logger.d("compressed (most likely chat room): $chatRoomId")
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
addDialectUIState = if (localChatRoom == null) {
AddDialectUIState.Error("Couldn't find the chat room")
} else {
AddDialectUIState.Loaded(
localChatRoom = localChatRoom,
)
}
}
}
fun addDialect(
localChatRoom: LocalChatRoom,
nameField: TextFieldState,
countryField: TextFieldState,
languageField: TextFieldState,
onSuccess: (dialectId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val country = countryField.text.toString()
val language = languageField.text.toString()
if (name.isBlank() || country.isBlank() || language.isBlank()) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val dialectInnerEvent = runCatching {
mantraRepository.addDialect(
localChatRoom = localChatRoom,
name = name,
country = country,
language = language,
userPublicKey = activeUserPublicKey,
)
}.onFailure { error ->
logger.e("Failed to add dialect", error)
}.getOrNull()
if (dialectInnerEvent != null) {
nameField.clearText()
countryField.clearText()
languageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(dialectInnerEvent.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "AddDialectViewModel"
fun factory(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddDialectViewModel(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
mantraRepository = mantraRepository
)
}
}
}
}

View File

@@ -55,6 +55,7 @@ class ChatRoomDetailViewModel(
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom,
artifacts = mantraRepository.getArtifacts(chatRoomId),
dialects = mantraRepository.getDialects(chatRoomId),
)
}
}

View File

@@ -0,0 +1,15 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface AddDialectUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
): AddDialectUIState
data class Error(
val message: String
): AddDialectUIState
data object Loading: AddDialectUIState
}

View File

@@ -1,12 +1,14 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface ChatRoomDetailUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
val artifacts: List<MantraArtifact> = emptyList(),
val dialects: List<MantraDialect> = emptyList(),
): ChatRoomDetailUIState
data class Error(