From 24a00f793a8731adf43fe8bf4f469df026667635 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 26 Jul 2026 00:42:59 +0200 Subject: [PATCH] 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 --- .../compose/database/dao/MantraDialectDao.kt | 4 + .../compose/database/model/MantraDialect.kt | 33 ++++- .../repository/DatabaseMantraRepository.kt | 44 ++++++ .../compose/nostr/nip30303/DialectEvent.kt | 11 +- .../compose/repository/MantraRepository.kt | 21 +++ .../ui/composable/AddArtifactScreen.kt | 135 +++++++++++++++++- .../ui/view/model/AddArtifactViewModel.kt | 38 ++++- .../ui/view/state/AddArtifactUIState.kt | 4 +- 8 files changed, 273 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDialectDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDialectDao.kt index abc0af57..898e88a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDialectDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDialectDao.kt @@ -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 } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt index ad70174f..af9cf73d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt @@ -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() + // Tag order matches DialectEvent.build so the event id round-trips. + tags = TagArrayBuilder() + .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, + 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 -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 68e3009a..75b7919c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -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 = + 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, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/DialectEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/DialectEvent.kt index b6eb1cc8..35d51498 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/DialectEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/DialectEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, content, createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, language, createdAt) { alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CountryTag.assemble(country)) + addUnique(LanguageTag.assemble(language)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index e18bcf85..4f3ea079 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -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 + + 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 = 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, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index c8ded66a..048be0d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -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 } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index b4216029..f251f1df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt index 80395d82..af593edb 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt @@ -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 = emptyList(), ): AddArtifactUIState data class Error(