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:
@@ -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>
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user