Add dialect selection/creation to the add-artifact flow

MantraArtifact requires a dialectId (FK), but the add-artifact screen had
no way to provide one. Let the user either reuse an existing source
dialect or create a new one inline.

- DatabaseMantraRepository.addDialect builds a DialectEvent, derives a
  MantraDialect with the real chatRoomId/userPublicKey, and persists it
  plus a MarmotInnerEvent rumor (kind = DialectEvent.KIND) for the
  outbound pipeline, mirroring addArtifact. getDialects exposes the chat
  room's dialects via a new MantraDialectDao query.
- MantraDialect.fromDialectEventTemplate mirrors the other models for
  consistent id derivation; DialectEvent.build now takes name/country/
  language (emitting NameTag/CountryTag/LanguageTag), and toDialectEvent's
  tag order matches build so the event id round-trips. Also fixes the
  DialectEvent.build / toDialectEvent phantom generics.
- AddArtifactViewModel loads the dialects on init and its addArtifact now
  takes existingDialectId: reuse it when set, otherwise create a new
  dialect (fields required only in create mode), then create the artifact.
- AddArtifactScreen shows a FilterChip row (one chip per existing dialect
  plus a "New dialect" chip); the name/country/language fields appear only
  when creating a new dialect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-07-26 00:42:59 +02:00
parent 12189dcd75
commit 24a00f793a
8 changed files with 273 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy.Companion.IGNORE
import androidx.room3.Query
import androidx.room3.Upsert
import press.mantra.compose.database.model.MantraDialect
@@ -10,4 +11,7 @@ import press.mantra.compose.database.model.MantraDialect
interface MantraDialectDao {
@Upsert
suspend fun upsert(mantraDialect: MantraDialect)
@Query("SELECT * FROM MantraDialect WHERE chatRoomId = :chatRoomId ORDER BY name")
suspend fun getDialectsByChatRoomId(chatRoomId: String): List<MantraDialect>
}

View File

@@ -5,11 +5,12 @@ import androidx.room3.ForeignKey
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip31Alts.AltTag
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
import press.mantra.compose.nostr.nip30303.tags.CountryTag
import press.mantra.compose.nostr.nip30303.tags.LanguageTag
import press.mantra.compose.nostr.nip30303.tags.NameTag
@@ -57,7 +58,9 @@ data class MantraDialect(
id = id,
pubKey = publicKey,
createdAt = createdAt.epochSeconds,
tags = TagArrayBuilder<ChunkEvent>()
// Tag order matches DialectEvent.build so the event id round-trips.
tags = TagArrayBuilder<DialectEvent>()
.addUnique(AltTag.assemble(DialectEvent.ALT_DESCRIPTION))
.addUnique(NameTag.assemble(name))
.addUnique(CountryTag.assemble(country))
.addUnique(LanguageTag.assemble(language))
@@ -68,6 +71,30 @@ data class MantraDialect(
}
companion object {
fun fromDialectEventTemplate(
dialectEventTemplate: EventTemplate<DialectEvent>,
chatRoomId: HexKey,
userPublicKey: HexKey,
): MantraDialect? {
return fromDialectEvent(
DialectEvent(
id = EventHasher.hashId(
pubKey = userPublicKey,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
createdAt = dialectEventTemplate.createdAt,
kind = dialectEventTemplate.kind,
),
content = dialectEventTemplate.content,
tags = dialectEventTemplate.tags,
createdAt = dialectEventTemplate.createdAt,
pubKey = userPublicKey,
sig = "", // Unsigned rumor
),
chatRoomId = chatRoomId,
)
}
fun fromDialectEvent(dialectEvent: DialectEvent, chatRoomId: HexKey): MantraDialect? {
return dialectEvent.name()?.let { name ->
dialectEvent.language()?.let { language ->

View File

@@ -6,9 +6,11 @@ import kotlinx.coroutines.CoroutineScope
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository
@@ -19,6 +21,48 @@ class DatabaseMantraRepository(
private val logger = Logger.withTag(TAG)
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> =
database.mantraDialectDao().getDialectsByChatRoomId(chatRoomId)
override suspend fun addDialect(
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect? {
val dialectEventTemplate = DialectEvent.build(
name = name,
country = country,
language = language,
)
val mantraDialect = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = dialectEventTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
val dialectInnerEvent = MarmotInnerEvent(
id = mantraDialect.id,
publicKey = mantraDialect.publicKey,
kind = DialectEvent.KIND,
createdAt = mantraDialect.createdAt,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
chatRoomId = mantraDialect.chatRoomId,
)
return try {
database.mantraDialectDao().upsert(mantraDialect)
database.marmotInnerEventDao().upsert(dialectInnerEvent)
mantraDialect
} catch (error: Throwable) {
logger.e("Failed to add dialect \"$name\" to chat room $chatRoomId", error)
null
}
}
override suspend fun addArtifact(
name: String,
url: String,

View File

@@ -32,11 +32,16 @@ class DialectEvent(
const val ALT_DESCRIPTION = "Dialect"
fun build(
content: String,
name: String,
country: String,
language: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
initializer: TagArrayBuilder<DialectEvent>.() -> Unit = {},
) = eventTemplate(KIND, language, createdAt) {
alt(ALT_DESCRIPTION)
addUnique(NameTag.assemble(name))
addUnique(CountryTag.assemble(country))
addUnique(LanguageTag.assemble(language))
initializer()
}
}

View File

@@ -1,9 +1,20 @@
package press.mantra.compose.repository
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MarmotInnerEvent
interface MantraRepository {
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
suspend fun addDialect(
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect?
suspend fun addArtifact(
name: String,
url: String,
@@ -27,6 +38,16 @@ interface MantraRepository {
const val DEFAULT_LICENSE = "cc"
val NO_OP_MANTRA_REPOSITORY = object: MantraRepository{
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
override suspend fun addDialect(
name: String,
country: String,
language: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraDialect? = null
override suspend fun addArtifact(
name: String,
url: String,

View File

@@ -3,6 +3,8 @@ package press.mantra.compose.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,12 +18,15 @@ import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.MoreVert
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.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -33,6 +38,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -55,7 +64,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
import press.mantra.compose.ui.view.model.AddArtifactViewModel
import press.mantra.compose.ui.view.state.AddArtifactUIState
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AddArtifactScreen(
activeUserPublicKey: HexKey,
@@ -98,6 +107,13 @@ fun AddArtifactScreen(
val nameFieldState = rememberTextFieldState()
val urlFieldState = rememberTextFieldState()
val versionLabelFieldState = rememberTextFieldState("1.0")
val dialectNameFieldState = rememberTextFieldState()
val dialectCountryFieldState = rememberTextFieldState()
val dialectLanguageFieldState = rememberTextFieldState()
// null = "New dialect" (show the create fields); otherwise the id of
// an existing dialect to reuse.
var selectedDialectId: String? by remember { mutableStateOf(null) }
Scaffold(
topBar = {
@@ -121,9 +137,10 @@ fun AddArtifactScreen(
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
// TODO: Source this from a dialect picker; addArtifact
// fails gracefully while this is null.
dialectId = null,
existingDialectId = selectedDialectId,
dialectNameField = dialectNameFieldState,
dialectCountryField = dialectCountryFieldState,
dialectLanguageField = dialectLanguageFieldState,
onSuccess = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Add Artifact")
@@ -239,6 +256,116 @@ fun AddArtifactScreen(
},
)
Text("Source dialect")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
FilterChip(
selected = selectedDialectId == null,
onClick = { selectedDialectId = null },
leadingIcon = {
Icon(
Icons.Default.Add,
contentDescription = "Create a new dialect"
)
},
label = { Text("New dialect") }
)
}
// Only collect new-dialect details when not reusing an existing one.
if (selectedDialectId == null) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectNameFieldState,
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 = dialectCountryFieldState,
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 = dialectLanguageFieldState,
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"
)
},
)
}
// TODO: Add Visibility
}
}

View File

@@ -51,7 +51,8 @@ class AddArtifactViewModel(
AddArtifactUIState.Error("Couldn't find the chat room")
} else {
AddArtifactUIState.Loaded(
localChatRoom = localChatRoom
localChatRoom = localChatRoom,
dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id),
)
}
}
@@ -62,7 +63,10 @@ class AddArtifactViewModel(
nameField: TextFieldState,
urlField: TextFieldState,
versionLabelField: TextFieldState,
dialectId: HexKey?,
existingDialectId: HexKey?,
dialectNameField: TextFieldState,
dialectCountryField: TextFieldState,
dialectLanguageField: TextFieldState,
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
license: String = MantraRepository.DEFAULT_LICENSE,
onSuccess: () -> Unit,
@@ -71,11 +75,16 @@ class AddArtifactViewModel(
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// dialectId is a required foreign key on MantraArtifact. Until a dialect
// picker supplies one, this validation fails gracefully instead of
// attempting an insert that would violate the constraint.
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
(creatingNewDialect && newDialectIncomplete)
) {
onFailure.invoke()
return
}
@@ -86,6 +95,20 @@ class AddArtifactViewModel(
viewModelScope.launch(Dispatchers.IO) {
val marmotInnerEvent = runCatching {
// Reuse the selected dialect, or create a new source dialect and
// reference it by id. addArtifact requires a valid dialectId.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId!!
}
mantraRepository.addArtifact(
name = name,
url = url,
@@ -104,6 +127,9 @@ class AddArtifactViewModel(
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
onSuccess.invoke()
} else {
onFailure.invoke()

View File

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