mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 10:58:23 +00:00
Merge pull request #496 from greenart7c3/claude/group-intents-by-type-m9ndb1
Group signing requests by type and payload in multi-event screens
This commit is contained in:
+49
-10
@@ -21,6 +21,7 @@ import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -151,19 +152,57 @@ fun BunkerMultiEventHomeScreen(
|
||||
Text(stringResource(R.string.select_deselect_all))
|
||||
}
|
||||
|
||||
val groups = remember(bunkerRequests) {
|
||||
groupRequests(bunkerRequests) {
|
||||
requestGroupKey(
|
||||
type = BunkerRequestUtils.getTypeFromBunker(it.request),
|
||||
eventKind = (it.request as? BunkerRequestSign)?.event?.kind,
|
||||
encryptedData = it.encryptedData,
|
||||
nip44v3Kind = BunkerRequestUtils.getNip44v3Kind(it.request),
|
||||
)
|
||||
}
|
||||
}
|
||||
val expandedGroups = remember { mutableStateMapOf<RequestGroupKey, Boolean>() }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
items(bunkerRequests, key = { it.request.id }) { bunkerRequest ->
|
||||
BunkerRequestCard(
|
||||
context = context,
|
||||
bunkerRequest = bunkerRequest,
|
||||
checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true,
|
||||
onToggleChecked = {
|
||||
val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true
|
||||
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current
|
||||
},
|
||||
)
|
||||
groups.forEach { (groupKey, groupItems) ->
|
||||
val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
|
||||
if (groups.size > 1) {
|
||||
item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
|
||||
val groupState = when {
|
||||
groupItems.all { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.On
|
||||
groupItems.none { MultiEventScreenIntents.checkedStates[it.request.id] ?: true } -> ToggleableState.Off
|
||||
else -> ToggleableState.Indeterminate
|
||||
}
|
||||
RequestGroupHeader(
|
||||
label = groupKey.toLabel(context),
|
||||
count = groupItems.size,
|
||||
state = groupState,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
val newValue = groupState != ToggleableState.On
|
||||
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue })
|
||||
},
|
||||
onExpandToggle = {
|
||||
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
items(groupItems, key = { it.request.id }) { bunkerRequest ->
|
||||
BunkerRequestCard(
|
||||
context = context,
|
||||
bunkerRequest = bunkerRequest,
|
||||
checked = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true,
|
||||
onToggleChecked = {
|
||||
val current = MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] ?: true
|
||||
MultiEventScreenIntents.checkedStates[bunkerRequest.request.id] = !current
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
-10
@@ -23,6 +23,7 @@ import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -122,19 +123,52 @@ fun IntentMultiEventHomeScreen(
|
||||
Text(stringResource(R.string.select_deselect_all))
|
||||
}
|
||||
|
||||
val groups = remember(intents) {
|
||||
groupRequests(intents) {
|
||||
requestGroupKey(it.type, it.event?.kind, it.encryptedData, it.nip44v3Kind)
|
||||
}
|
||||
}
|
||||
val expandedGroups = remember { mutableStateMapOf<RequestGroupKey, Boolean>() }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
items(intents, key = { it.id }) { intent ->
|
||||
IntentRequestCard(
|
||||
context = context,
|
||||
intent = intent,
|
||||
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
|
||||
onToggleChecked = {
|
||||
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true
|
||||
MultiEventScreenIntents.checkedStates[intent.id] = !current
|
||||
},
|
||||
)
|
||||
groups.forEach { (groupKey, groupIntents) ->
|
||||
val expanded = groups.size == 1 || (expandedGroups[groupKey] ?: false)
|
||||
if (groups.size > 1) {
|
||||
item(key = "group-header:${groupKey.type.name}:${groupKey.payload?.name ?: ""}:${groupKey.kind ?: ""}") {
|
||||
val groupState = when {
|
||||
groupIntents.all { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.On
|
||||
groupIntents.none { MultiEventScreenIntents.checkedStates[it.id] ?: true } -> ToggleableState.Off
|
||||
else -> ToggleableState.Indeterminate
|
||||
}
|
||||
RequestGroupHeader(
|
||||
label = groupKey.toLabel(context),
|
||||
count = groupIntents.size,
|
||||
state = groupState,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
val newValue = groupState != ToggleableState.On
|
||||
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue })
|
||||
},
|
||||
onExpandToggle = {
|
||||
expandedGroups[groupKey] = !(expandedGroups[groupKey] ?: false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
items(groupIntents, key = { it.id }) { intent ->
|
||||
IntentRequestCard(
|
||||
context = context,
|
||||
intent = intent,
|
||||
checked = MultiEventScreenIntents.checkedStates[intent.id] ?: true,
|
||||
onToggleChecked = {
|
||||
val current = MultiEventScreenIntents.checkedStates[intent.id] ?: true
|
||||
MultiEventScreenIntents.checkedStates[intent.id] = !current
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.greenart7c3.nostrsigner.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.greenart7c3.nostrsigner.R
|
||||
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.EncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.Permission
|
||||
import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.SignerType
|
||||
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes
|
||||
|
||||
enum class RequestPayloadShape {
|
||||
EVENT,
|
||||
TAG_ARRAY,
|
||||
CLEAR_TEXT,
|
||||
PRIVATE_ZAP,
|
||||
}
|
||||
|
||||
data class RequestGroupKey(
|
||||
val type: SignerType,
|
||||
val kind: Int?,
|
||||
val payload: RequestPayloadShape?,
|
||||
)
|
||||
|
||||
fun requestGroupKey(
|
||||
type: SignerType,
|
||||
eventKind: Int?,
|
||||
encryptedData: EncryptedDataKind?,
|
||||
nip44v3Kind: Int?,
|
||||
): RequestGroupKey = when {
|
||||
type == SignerType.SIGN_EVENT -> RequestGroupKey(type, eventKind, null)
|
||||
type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> RequestGroupKey(type, nip44v3Kind, null)
|
||||
type in encryptDecryptSignerTypes -> when (encryptedData) {
|
||||
is EventEncryptedDataKind -> RequestGroupKey(type, encryptedData.event.kind, RequestPayloadShape.EVENT)
|
||||
is TagArrayEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.TAG_ARRAY)
|
||||
is ClearTextEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.CLEAR_TEXT)
|
||||
is PrivateZapEncryptedDataKind -> RequestGroupKey(type, null, RequestPayloadShape.PRIVATE_ZAP)
|
||||
else -> RequestGroupKey(type, null, null)
|
||||
}
|
||||
else -> RequestGroupKey(type, null, null)
|
||||
}
|
||||
|
||||
fun <T> groupRequests(
|
||||
items: List<T>,
|
||||
keyOf: (T) -> RequestGroupKey,
|
||||
): List<Pair<RequestGroupKey, List<T>>> = items.groupBy(keyOf).toList()
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.first.type.ordinal },
|
||||
{ it.first.payload?.ordinal ?: -1 },
|
||||
{ it.first.kind ?: -1 },
|
||||
),
|
||||
)
|
||||
|
||||
fun RequestGroupKey.toLabel(context: Context): String {
|
||||
val isEncrypt = type.name.contains("ENCRYPT")
|
||||
val nip = type.name.split("_").first()
|
||||
return when {
|
||||
type == SignerType.CONNECT -> context.getString(R.string.connect)
|
||||
type == SignerType.SIGN_EVENT -> Permission("sign_event", kind).toLocalizedString(context)
|
||||
payload == RequestPayloadShape.EVENT -> {
|
||||
val kindLabel = Permission("sign_event", kind).toLocalizedString(context)
|
||||
if (isEncrypt) {
|
||||
context.getString(R.string.encrypt_with, kindLabel, nip)
|
||||
} else {
|
||||
context.getString(R.string.read_from_encrypted_content, kindLabel, nip)
|
||||
}
|
||||
}
|
||||
payload == RequestPayloadShape.TAG_ARRAY -> if (isEncrypt) {
|
||||
context.getString(R.string.encrypt_this_list_of_tags_with, nip)
|
||||
} else {
|
||||
context.getString(R.string.read_this_list_of_tags_from_encrypted_content, nip)
|
||||
}
|
||||
payload == RequestPayloadShape.CLEAR_TEXT -> if (isEncrypt) {
|
||||
context.getString(R.string.encrypt_this_text_with, nip)
|
||||
} else {
|
||||
context.getString(R.string.read_this_text_from_encrypted_content, nip)
|
||||
}
|
||||
payload == RequestPayloadShape.PRIVATE_ZAP -> context.getString(R.string.decrypt_zap_event).replaceFirstChar { it.uppercase() }
|
||||
(type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) && kind != null -> {
|
||||
val kindLabel = Permission("sign_event", kind).toLocalizedString(context)
|
||||
if (isEncrypt) {
|
||||
context.getString(R.string.encrypt_with, kindLabel, nip)
|
||||
} else {
|
||||
context.getString(R.string.read_from_encrypted_content, kindLabel, nip)
|
||||
}
|
||||
}
|
||||
else -> Permission(type.name.lowercase(), null).toLocalizedString(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RequestGroupHeader(
|
||||
label: String,
|
||||
count: Int,
|
||||
state: ToggleableState,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onExpandToggle: () -> Unit,
|
||||
) {
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (expanded) 180f else 0f,
|
||||
label = "group header chevron rotation",
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onExpandToggle() },
|
||||
) {
|
||||
TriStateCheckbox(
|
||||
state = state,
|
||||
onClick = onToggle,
|
||||
)
|
||||
Text(
|
||||
text = "$label ($count)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.rotate(rotation),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.greenart7c3.nostrsigner.ui.components
|
||||
|
||||
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.EventEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.PrivateZapEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.models.SignerType
|
||||
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
|
||||
import com.greenart7c3.nostrsigner.service.model.AmberEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RequestGroupUtilsTest {
|
||||
|
||||
private fun amberEvent(kind: Int) = AmberEvent("id", "pubkey", 0L, kind, arrayOf(), "", "")
|
||||
|
||||
private fun eventData(kind: Int) = EventEncryptedDataKind(amberEvent(kind), null, "result")
|
||||
|
||||
@Test
|
||||
fun `sign event groups by event kind`() {
|
||||
val key1 = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null)
|
||||
val key1b = requestGroupKey(SignerType.SIGN_EVENT, 1, null, null)
|
||||
val key22242 = requestGroupKey(SignerType.SIGN_EVENT, 22242, null, null)
|
||||
|
||||
assertEquals(key1, key1b)
|
||||
assertEquals(1, key1.kind)
|
||||
assertEquals(22242, key22242.kind)
|
||||
assertEquals(SignerType.SIGN_EVENT, key1.type)
|
||||
assertEquals(null, key1.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt decrypt separates by payload shape`() {
|
||||
val eventKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, eventData(4), null)
|
||||
val tagKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, TagArrayEncryptedDataKind(arrayOf(), "r"), null)
|
||||
val textKey = requestGroupKey(SignerType.NIP44_DECRYPT, null, ClearTextEncryptedDataKind("t", "r"), null)
|
||||
val zapKey = requestGroupKey(SignerType.DECRYPT_ZAP_EVENT, null, PrivateZapEncryptedDataKind("r"), null)
|
||||
|
||||
assertEquals(RequestPayloadShape.EVENT, eventKey.payload)
|
||||
assertEquals(4, eventKey.kind)
|
||||
assertEquals(RequestPayloadShape.TAG_ARRAY, tagKey.payload)
|
||||
assertEquals(null, tagKey.kind)
|
||||
assertEquals(RequestPayloadShape.CLEAR_TEXT, textKey.payload)
|
||||
assertEquals(RequestPayloadShape.PRIVATE_ZAP, zapKey.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt decrypt event payloads separate by embedded event kind`() {
|
||||
val kind1 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null)
|
||||
val kind1b = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(1), null)
|
||||
val kind7 = requestGroupKey(SignerType.NIP44_ENCRYPT, null, eventData(7), null)
|
||||
|
||||
assertEquals(kind1, kind1b)
|
||||
assertEquals(1, kind1.kind)
|
||||
assertEquals(7, kind7.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nip44 v3 groups by explicit kind`() {
|
||||
val enc9 = requestGroupKey(SignerType.NIP44_V3_ENCRYPT, null, null, 9)
|
||||
val dec9 = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, 9)
|
||||
val decNull = requestGroupKey(SignerType.NIP44_V3_DECRYPT, null, null, null)
|
||||
|
||||
assertEquals(9, enc9.kind)
|
||||
assertEquals(9, dec9.kind)
|
||||
assertEquals(null, decNull.kind)
|
||||
assertEquals(null, enc9.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other types group by type alone`() {
|
||||
val connect = requestGroupKey(SignerType.CONNECT, null, null, null)
|
||||
val pubKey = requestGroupKey(SignerType.GET_PUBLIC_KEY, null, null, null)
|
||||
|
||||
assertEquals(RequestGroupKey(SignerType.CONNECT, null, null), connect)
|
||||
assertEquals(RequestGroupKey(SignerType.GET_PUBLIC_KEY, null, null), pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupRequests sorts by type ordinal then payload then kind`() {
|
||||
val items = listOf(
|
||||
RequestGroupKey(SignerType.SIGN_EVENT, 22242, null),
|
||||
RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT),
|
||||
RequestGroupKey(SignerType.CONNECT, null, null),
|
||||
RequestGroupKey(SignerType.SIGN_EVENT, 1, null),
|
||||
RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT),
|
||||
)
|
||||
|
||||
val groups = groupRequests(items) { it }
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
RequestGroupKey(SignerType.CONNECT, null, null),
|
||||
RequestGroupKey(SignerType.SIGN_EVENT, 1, null),
|
||||
RequestGroupKey(SignerType.SIGN_EVENT, 22242, null),
|
||||
RequestGroupKey(SignerType.NIP44_DECRYPT, 4, RequestPayloadShape.EVENT),
|
||||
RequestGroupKey(SignerType.NIP44_DECRYPT, null, RequestPayloadShape.CLEAR_TEXT),
|
||||
),
|
||||
groups.map { it.first },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupRequests preserves input order within a group`() {
|
||||
data class Item(val id: String, val kind: Int)
|
||||
|
||||
val items = listOf(
|
||||
Item("a", 1),
|
||||
Item("b", 7),
|
||||
Item("c", 1),
|
||||
Item("d", 1),
|
||||
)
|
||||
|
||||
val groups = groupRequests(items) { RequestGroupKey(SignerType.SIGN_EVENT, it.kind, null) }
|
||||
|
||||
assertEquals(listOf("a", "c", "d"), groups.first { it.first.kind == 1 }.second.map { it.id })
|
||||
assertEquals(listOf("b"), groups.first { it.first.kind == 7 }.second.map { it.id })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user