mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
Group multi-request approval lists by type and kind
When several intents or bunker requests are shown at once, the list is now sectioned by request type and kind instead of arrival order: - sign_event requests group by event kind - encrypt/decrypt requests separate by payload shape (event, tag array, clear text, private zap) and, for event payloads, by embedded kind - NIP-44 v3 requests group by their explicit kind parameter Each section header shows a localized label with a count and a tri-state checkbox to select or deselect the whole group. Grouping is presentation-only; approve/discard behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TioEdhZRp9W78DbsHzEypZ
This commit is contained in:
+40
-10
@@ -151,19 +151,49 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
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) ->
|
||||
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,
|
||||
onToggle = {
|
||||
val newValue = groupState != ToggleableState.On
|
||||
MultiEventScreenIntents.checkedStates.putAll(groupItems.associate { it.request.id to newValue })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-10
@@ -122,19 +122,44 @@ 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)
|
||||
}
|
||||
}
|
||||
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) ->
|
||||
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,
|
||||
onToggle = {
|
||||
val newValue = groupState != ToggleableState.On
|
||||
MultiEventScreenIntents.checkedStates.putAll(groupIntents.associate { it.id to newValue })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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,126 @@
|
||||
package com.greenart7c3.nostrsigner.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TriStateCheckbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
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,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggle() },
|
||||
) {
|
||||
TriStateCheckbox(
|
||||
state = state,
|
||||
onClick = onToggle,
|
||||
)
|
||||
Text(
|
||||
text = "$label ($count)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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