feat(broadcast): Add transparent event broadcasting feedback (#1682)
Phase 1 - Core infrastructure: - BroadcastModels: BroadcastEvent, RelayResult, BroadcastStatus data classes - BroadcastTracker: State management with live progress updates - BroadcastBanner: Global progress indicator (animated) - BroadcastSnackbar: Result notifications with actions - BroadcastDetailsSheet: Relay status detail bottom sheet Refs: #1682 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.broadcast
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a relay's response to an event publish.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
sealed class RelayResult {
|
||||||
|
/** Relay accepted the event (OK message with success=true) */
|
||||||
|
data object Success : RelayResult()
|
||||||
|
|
||||||
|
/** Relay rejected the event (OK message with success=false) */
|
||||||
|
data class Error(
|
||||||
|
val code: String,
|
||||||
|
val message: String?,
|
||||||
|
) : RelayResult()
|
||||||
|
|
||||||
|
/** Relay did not respond within timeout */
|
||||||
|
data object Timeout : RelayResult()
|
||||||
|
|
||||||
|
/** Waiting for relay response */
|
||||||
|
data object Pending : RelayResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall status of a broadcast operation.
|
||||||
|
*/
|
||||||
|
enum class BroadcastStatus {
|
||||||
|
/** Currently waiting for relay responses */
|
||||||
|
IN_PROGRESS,
|
||||||
|
|
||||||
|
/** All relays accepted the event */
|
||||||
|
SUCCESS,
|
||||||
|
|
||||||
|
/** Some relays accepted, some failed */
|
||||||
|
PARTIAL,
|
||||||
|
|
||||||
|
/** No relays accepted the event */
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks a single event broadcast to multiple relays.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class BroadcastEvent(
|
||||||
|
val id: String,
|
||||||
|
val eventId: HexKey,
|
||||||
|
val eventName: String,
|
||||||
|
val kind: Int,
|
||||||
|
val targetRelays: List<NormalizedRelayUrl>,
|
||||||
|
val results: Map<NormalizedRelayUrl, RelayResult> = emptyMap(),
|
||||||
|
val status: BroadcastStatus = BroadcastStatus.IN_PROGRESS,
|
||||||
|
val startedAt: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
/** Number of relays that accepted the event */
|
||||||
|
val successCount: Int
|
||||||
|
get() = results.count { it.value is RelayResult.Success }
|
||||||
|
|
||||||
|
/** Number of relays that rejected or timed out */
|
||||||
|
val failureCount: Int
|
||||||
|
get() = results.count { it.value is RelayResult.Error || it.value is RelayResult.Timeout }
|
||||||
|
|
||||||
|
/** Number of relays still pending response */
|
||||||
|
val pendingCount: Int
|
||||||
|
get() = targetRelays.size - results.size
|
||||||
|
|
||||||
|
/** Total number of target relays */
|
||||||
|
val totalRelays: Int
|
||||||
|
get() = targetRelays.size
|
||||||
|
|
||||||
|
/** Progress as a fraction (0.0 to 1.0) */
|
||||||
|
val progress: Float
|
||||||
|
get() = if (totalRelays == 0) 0f else results.size.toFloat() / totalRelays
|
||||||
|
|
||||||
|
/** Whether all relays have responded */
|
||||||
|
val isComplete: Boolean
|
||||||
|
get() = results.size >= targetRelays.size
|
||||||
|
|
||||||
|
/** List of relays that failed (for retry) */
|
||||||
|
val failedRelays: List<NormalizedRelayUrl>
|
||||||
|
get() = results.filter { it.value is RelayResult.Error || it.value is RelayResult.Timeout }.keys.toList()
|
||||||
|
|
||||||
|
/** Creates a copy with an updated relay result */
|
||||||
|
fun withResult(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
result: RelayResult,
|
||||||
|
): BroadcastEvent {
|
||||||
|
val newResults = results + (relay to result)
|
||||||
|
val newStatus =
|
||||||
|
when {
|
||||||
|
newResults.size < targetRelays.size -> BroadcastStatus.IN_PROGRESS
|
||||||
|
newResults.all { it.value is RelayResult.Success } -> BroadcastStatus.SUCCESS
|
||||||
|
newResults.none { it.value is RelayResult.Success } -> BroadcastStatus.FAILED
|
||||||
|
else -> BroadcastStatus.PARTIAL
|
||||||
|
}
|
||||||
|
return copy(results = newResults, status = newStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a broadcast operation, returned after all relays respond or timeout.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class BroadcastResult(
|
||||||
|
val broadcast: BroadcastEvent,
|
||||||
|
val isSuccess: Boolean,
|
||||||
|
) {
|
||||||
|
val successCount: Int get() = broadcast.successCount
|
||||||
|
val totalRelays: Int get() = broadcast.totalRelays
|
||||||
|
}
|
||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.broadcast
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks event broadcasts to relays with live progress updates.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - Real-time progress as relays respond
|
||||||
|
* - Detailed per-relay success/error information
|
||||||
|
* - Retry functionality for failed relays
|
||||||
|
*/
|
||||||
|
class BroadcastTracker {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BroadcastTracker"
|
||||||
|
private const val TIMEOUT_SECONDS = 15L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _activeBroadcasts = MutableStateFlow<List<BroadcastEvent>>(emptyList())
|
||||||
|
val activeBroadcasts: StateFlow<List<BroadcastEvent>> = _activeBroadcasts.asStateFlow()
|
||||||
|
|
||||||
|
private val _completedBroadcast = MutableSharedFlow<BroadcastEvent>(extraBufferCapacity = 10)
|
||||||
|
val completedBroadcast: SharedFlow<BroadcastEvent> = _completedBroadcast.asSharedFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks an event broadcast to relays with live progress updates.
|
||||||
|
*
|
||||||
|
* @param event The Nostr event to broadcast
|
||||||
|
* @param eventName Human-readable name (e.g., "Boost", "Reaction")
|
||||||
|
* @param relays Target relays to send to
|
||||||
|
* @param client The Nostr client for sending
|
||||||
|
* @return BroadcastResult with final status
|
||||||
|
*/
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
suspend fun trackBroadcast(
|
||||||
|
event: Event,
|
||||||
|
eventName: String,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
client: INostrClient,
|
||||||
|
): BroadcastResult {
|
||||||
|
val trackingId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
val broadcast =
|
||||||
|
BroadcastEvent(
|
||||||
|
id = trackingId,
|
||||||
|
eventId = event.id,
|
||||||
|
eventName = eventName,
|
||||||
|
kind = event.kind,
|
||||||
|
targetRelays = relays.toList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to active broadcasts
|
||||||
|
_activeBroadcasts.update { it + broadcast }
|
||||||
|
|
||||||
|
Log.d(TAG, "Starting broadcast $trackingId: $eventName (kind ${event.kind}) to ${relays.size} relays")
|
||||||
|
|
||||||
|
val resultChannel = Channel<RelayResponse>(UNLIMITED)
|
||||||
|
|
||||||
|
val subscription =
|
||||||
|
object : IRelayClientListener {
|
||||||
|
override fun onCannotConnect(
|
||||||
|
relay: IRelayClient,
|
||||||
|
errorMessage: String,
|
||||||
|
) {
|
||||||
|
if (relay.url in relays) {
|
||||||
|
resultChannel.trySend(
|
||||||
|
RelayResponse(
|
||||||
|
relay = relay.url,
|
||||||
|
result = RelayResult.Error("CONNECTION_ERROR", errorMessage),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnected(relay: IRelayClient) {
|
||||||
|
if (relay.url in relays) {
|
||||||
|
resultChannel.trySend(
|
||||||
|
RelayResponse(
|
||||||
|
relay = relay.url,
|
||||||
|
result = RelayResult.Error("DISCONNECTED", "Relay disconnected"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIncomingMessage(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msgStr: String,
|
||||||
|
msg: Message,
|
||||||
|
) {
|
||||||
|
super.onIncomingMessage(relay, msgStr, msg)
|
||||||
|
|
||||||
|
when (msg) {
|
||||||
|
is OkMessage -> {
|
||||||
|
if (msg.eventId == event.id) {
|
||||||
|
val result =
|
||||||
|
if (msg.success) {
|
||||||
|
RelayResult.Success
|
||||||
|
} else {
|
||||||
|
val (code, message) = parseOkError(msg.message)
|
||||||
|
RelayResult.Error(code, message)
|
||||||
|
}
|
||||||
|
resultChannel.trySend(RelayResponse(relay.url, result))
|
||||||
|
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.subscribe(subscription)
|
||||||
|
|
||||||
|
val finalBroadcast =
|
||||||
|
coroutineScope {
|
||||||
|
val resultCollector =
|
||||||
|
async {
|
||||||
|
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||||
|
var currentBroadcast = broadcast
|
||||||
|
|
||||||
|
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
|
||||||
|
while (receivedRelays.size < relays.size) {
|
||||||
|
val response = resultChannel.receive()
|
||||||
|
|
||||||
|
// Skip if already received (don't override success)
|
||||||
|
if (response.relay in receivedRelays) continue
|
||||||
|
|
||||||
|
receivedRelays.add(response.relay)
|
||||||
|
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
|
||||||
|
|
||||||
|
// Update active broadcasts with new progress
|
||||||
|
_activeBroadcasts.update { list ->
|
||||||
|
list.map { if (it.id == trackingId) currentBroadcast else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark remaining relays as timeout
|
||||||
|
relays.filter { it !in receivedRelays }.forEach { relay ->
|
||||||
|
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBroadcast
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send after setting up listener
|
||||||
|
client.send(event, relays)
|
||||||
|
|
||||||
|
resultCollector.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
client.unsubscribe(subscription)
|
||||||
|
resultChannel.close()
|
||||||
|
|
||||||
|
// Remove from active, emit to completed
|
||||||
|
_activeBroadcasts.update { list -> list.filter { it.id != trackingId } }
|
||||||
|
_completedBroadcast.emit(finalBroadcast)
|
||||||
|
|
||||||
|
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
|
||||||
|
|
||||||
|
return BroadcastResult(
|
||||||
|
broadcast = finalBroadcast,
|
||||||
|
isSuccess = finalBroadcast.successCount > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries sending an event to failed relays.
|
||||||
|
*
|
||||||
|
* @param originalBroadcast The original broadcast with failures
|
||||||
|
* @param event The original event to resend
|
||||||
|
* @param client The Nostr client
|
||||||
|
* @param specificRelay Optional specific relay to retry (null = all failed)
|
||||||
|
*/
|
||||||
|
suspend fun retry(
|
||||||
|
originalBroadcast: BroadcastEvent,
|
||||||
|
event: Event,
|
||||||
|
client: INostrClient,
|
||||||
|
specificRelay: NormalizedRelayUrl? = null,
|
||||||
|
): BroadcastResult {
|
||||||
|
val relaysToRetry =
|
||||||
|
if (specificRelay != null) {
|
||||||
|
setOf(specificRelay)
|
||||||
|
} else {
|
||||||
|
originalBroadcast.failedRelays.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relaysToRetry.isEmpty()) {
|
||||||
|
return BroadcastResult(originalBroadcast, originalBroadcast.successCount > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackBroadcast(
|
||||||
|
event = event,
|
||||||
|
eventName = "${originalBroadcast.eventName} (retry)",
|
||||||
|
relays = relaysToRetry,
|
||||||
|
client = client,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets details for a specific broadcast by tracking ID.
|
||||||
|
*/
|
||||||
|
fun getActiveBroadcast(trackingId: String): BroadcastEvent? = _activeBroadcasts.value.find { it.id == trackingId }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all active broadcasts (e.g., on logout).
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
_activeBroadcasts.update { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses NIP-20 OK error message into code and description.
|
||||||
|
* Format: "prefix: message" or just "message"
|
||||||
|
*/
|
||||||
|
private fun parseOkError(message: String): Pair<String, String?> {
|
||||||
|
val parts = message.split(":", limit = 2)
|
||||||
|
return if (parts.size == 2) {
|
||||||
|
parts[0].trim().uppercase() to parts[1].trim()
|
||||||
|
} else {
|
||||||
|
"ERROR" to message.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RelayResponse(
|
||||||
|
val relay: NormalizedRelayUrl,
|
||||||
|
val result: RelayResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.broadcast
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner showing active broadcast progress.
|
||||||
|
* Displayed above bottom navigation when events are being sent to relays.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BroadcastBanner(
|
||||||
|
broadcasts: List<BroadcastEvent>,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = broadcasts.isNotEmpty(),
|
||||||
|
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onTap),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.animateContentSize(),
|
||||||
|
) {
|
||||||
|
if (broadcasts.size == 1) {
|
||||||
|
SingleBroadcastContent(broadcasts.first())
|
||||||
|
} else {
|
||||||
|
MultipleBroadcastsContent(broadcasts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sync,
|
||||||
|
contentDescription = "Broadcasting",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Broadcasting ${broadcast.eventName} (kind ${broadcast.kind})",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "[${broadcast.results.size}/${broadcast.totalRelays}]",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = broadcast.progress,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
label = "progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MultipleBroadcastsContent(broadcasts: List<BroadcastEvent>) {
|
||||||
|
val totalRelays = broadcasts.sumOf { it.totalRelays }
|
||||||
|
val completedResponses = broadcasts.sumOf { it.results.size }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Sync,
|
||||||
|
contentDescription = "Broadcasting",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Broadcasting ${broadcasts.size} events...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "[$completedResponses/$totalRelays]",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
val progress = if (totalRelays > 0) completedResponses.toFloat() / totalRelays else 0f
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
label = "progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.broadcast
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal bottom sheet showing detailed relay results for a broadcast.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BroadcastDetailsSheet(
|
||||||
|
broadcast: BroadcastEvent,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRetryRelay: (NormalizedRelayUrl) -> Unit,
|
||||||
|
onRetryAllFailed: () -> Unit,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Text(
|
||||||
|
text = "Broadcast Results",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
StatusIcon(broadcast.status)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${broadcast.eventName} (kind ${broadcast.kind})",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${broadcast.successCount}/${broadcast.totalRelays} relays",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color =
|
||||||
|
when (broadcast.status) {
|
||||||
|
BroadcastStatus.SUCCESS -> Color(0xFF22C55E)
|
||||||
|
BroadcastStatus.PARTIAL -> Color(0xFFF59E0B)
|
||||||
|
BroadcastStatus.FAILED -> Color(0xFFEF4444)
|
||||||
|
BroadcastStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Relay list
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
) {
|
||||||
|
items(broadcast.targetRelays) { relay ->
|
||||||
|
val result = broadcast.results[relay] ?: RelayResult.Pending
|
||||||
|
RelayResultRow(
|
||||||
|
relay = relay,
|
||||||
|
result = result,
|
||||||
|
onRetry = { onRetryRelay(relay) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broadcast.failedRelays.isNotEmpty()) {
|
||||||
|
Button(
|
||||||
|
onClick = onRetryAllFailed,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Retry Failed (${broadcast.failedRelays.size})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusIcon(status: BroadcastStatus) {
|
||||||
|
val (icon, tint) =
|
||||||
|
when (status) {
|
||||||
|
BroadcastStatus.SUCCESS -> Icons.Default.CheckCircle to Color(0xFF22C55E)
|
||||||
|
BroadcastStatus.PARTIAL -> Icons.Default.Error to Color(0xFFF59E0B)
|
||||||
|
BroadcastStatus.FAILED -> Icons.Default.Error to Color(0xFFEF4444)
|
||||||
|
BroadcastStatus.IN_PROGRESS -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = status.name,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RelayResultRow(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
result: RelayResult,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Status icon
|
||||||
|
val (icon, tint) =
|
||||||
|
when (result) {
|
||||||
|
is RelayResult.Success -> Icons.Default.CheckCircle to Color(0xFF22C55E)
|
||||||
|
is RelayResult.Error -> Icons.Default.Error to Color(0xFFEF4444)
|
||||||
|
is RelayResult.Timeout -> Icons.Default.HourglassEmpty to Color(0xFFF59E0B)
|
||||||
|
is RelayResult.Pending -> Icons.Default.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Relay URL
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = relay.displayUrl(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error message if present
|
||||||
|
when (result) {
|
||||||
|
is RelayResult.Error -> {
|
||||||
|
Text(
|
||||||
|
text = "[${result.code}]${result.message?.let { " $it" } ?: ""}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = Color(0xFFEF4444),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RelayResult.Timeout -> {
|
||||||
|
Text(
|
||||||
|
text = "Timeout",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color(0xFFF59E0B),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry button for failed relays
|
||||||
|
if (result is RelayResult.Error || result is RelayResult.Timeout) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Retry",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.broadcast
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarData
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.SnackbarVisuals
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom snackbar visuals for broadcast results.
|
||||||
|
*/
|
||||||
|
data class BroadcastSnackbarVisuals(
|
||||||
|
val broadcast: BroadcastEvent,
|
||||||
|
override val actionLabel: String? = "View",
|
||||||
|
override val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||||
|
override val withDismissAction: Boolean = true,
|
||||||
|
) : SnackbarVisuals {
|
||||||
|
override val message: String
|
||||||
|
get() =
|
||||||
|
when (broadcast.status) {
|
||||||
|
BroadcastStatus.SUCCESS -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
|
||||||
|
BroadcastStatus.PARTIAL -> "${broadcast.eventName} sent to ${broadcast.successCount}/${broadcast.totalRelays} relays"
|
||||||
|
BroadcastStatus.FAILED -> "${broadcast.eventName} failed - 0/${broadcast.totalRelays} relays"
|
||||||
|
BroadcastStatus.IN_PROGRESS -> "Broadcasting ${broadcast.eventName}..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snackbar host that listens to completed broadcasts and shows result notifications.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BroadcastSnackbarHost(
|
||||||
|
completedBroadcast: SharedFlow<BroadcastEvent>,
|
||||||
|
onViewDetails: (BroadcastEvent) -> Unit,
|
||||||
|
onRetry: (BroadcastEvent) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(completedBroadcast) {
|
||||||
|
completedBroadcast.collect { broadcast ->
|
||||||
|
val visuals = BroadcastSnackbarVisuals(broadcast)
|
||||||
|
val result = snackbarHostState.showSnackbar(visuals)
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
SnackbarResult.ActionPerformed -> {
|
||||||
|
if (broadcast.status == BroadcastStatus.FAILED) {
|
||||||
|
onRetry(broadcast)
|
||||||
|
} else {
|
||||||
|
onViewDetails(broadcast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SnackbarResult.Dismissed -> { /* No action */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = modifier.padding(bottom = 8.dp),
|
||||||
|
) { snackbarData ->
|
||||||
|
BroadcastSnackbarContent(snackbarData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BroadcastSnackbarContent(snackbarData: SnackbarData) {
|
||||||
|
val visuals = snackbarData.visuals
|
||||||
|
val broadcastVisuals = visuals as? BroadcastSnackbarVisuals
|
||||||
|
|
||||||
|
val containerColor =
|
||||||
|
when (broadcastVisuals?.broadcast?.status) {
|
||||||
|
BroadcastStatus.FAILED -> Color(0xFF7F1D1D) // Dark red
|
||||||
|
BroadcastStatus.PARTIAL -> Color(0xFF78350F) // Dark amber
|
||||||
|
else -> Color(0xFF1E3A5F) // Dark blue (default)
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionLabel =
|
||||||
|
when (broadcastVisuals?.broadcast?.status) {
|
||||||
|
BroadcastStatus.FAILED -> "Retry"
|
||||||
|
else -> "View"
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar(
|
||||||
|
action = {
|
||||||
|
snackbarData.visuals.actionLabel?.let {
|
||||||
|
TextButton(onClick = { snackbarData.performAction() }) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissAction =
|
||||||
|
if (visuals.withDismissAction) {
|
||||||
|
{
|
||||||
|
TextButton(onClick = { snackbarData.dismiss() }) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
containerColor = containerColor,
|
||||||
|
) {
|
||||||
|
Text(visuals.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user