feat(commons): SigningOpState.Progress for per-step in-flight UI

Adds a Progress(current, total, label?) variant to SigningOpState so
multi-step signing operations (NIP-17 group sends via remote signer,
batched zaps) can show "Encrypting via remote signer (3 of 5)" rather
than an opaque indeterminate spinner.

Backwards compatible:
- Pending stays a data object — existing callers' `is Pending` checks
  unaffected.
- New helper `isPending()` returns true for both Pending and Progress;
  SigningState.execute uses it so a second execute() during Progress
  returns null (matching the old single-flight semantics).
- SigningAwareButton renders both Pending and Progress as a spinner;
  callers wanting the counter must read the state directly.
- SigningStatusBar adds a Progress branch that shows "<label>
  (<current> of <total>)" — uses "Signing" as default label.

New `SigningState.updateProgress(current, total, label?)` lets the
in-flight block emit progress updates between Pending start and
finish. No-op when state is Idle/Error so callers don't have to gate.

Wire-up for NIP-17 bunker sends (publishing per-recipient progress
during NIP17Factory.createWraps' mapNotNullAsync) is a follow-up
that depends on threading the SigningState reference into the
factory's signing lambda; the substrate is here.
This commit is contained in:
nrobi144
2026-07-09 07:33:42 +03:00
parent 4ce21e7034
commit 49d31ccb44
3 changed files with 52 additions and 2 deletions
@@ -43,7 +43,7 @@ fun SigningAwareButton(
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
when (signingState.state) {
is SigningOpState.Pending -> {
is SigningOpState.Pending, is SigningOpState.Progress -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
@@ -37,11 +37,29 @@ sealed class SigningOpState {
data object Pending : SigningOpState()
/**
* Signing is in flight AND has a known step count — typically a NIP-17
* group send via a remote signer (bunker), where the UI can usefully show
* "Encrypting via remote signer ([current] of [total])".
*
* Treated as Pending for all is-pending checks via [isPending] below;
* existing callers that branch on `is Pending` keep working unchanged.
* New callers can render the counter when [SigningOpState] is `Progress`.
*/
data class Progress(
val current: Int,
val total: Int,
val label: String? = null,
) : SigningOpState()
data class Error(
val message: String,
) : SigningOpState()
}
/** True when signing is in flight, regardless of whether step counts are known. */
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
/**
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
@@ -86,7 +104,7 @@ class SigningState {
private set
suspend fun <T> execute(block: suspend () -> T): T? {
if (state is SigningOpState.Pending) return null
if (state.isPending()) return null
state = SigningOpState.Pending
GlobalSigningStatus.onPending()
errorMessage = null
@@ -114,6 +132,23 @@ class SigningState {
}
}
/**
* Update the in-flight signing state with a progress counter. Use during
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
* show the user how far the in-flight op has progressed.
*
* Only takes effect while [state] is Pending or Progress — no-op
* otherwise so callers don't have to gate on Idle/Error themselves.
*/
fun updateProgress(
current: Int,
total: Int,
label: String? = null,
) {
if (!state.isPending()) return
state = SigningOpState.Progress(current, total, label)
}
private fun setError(message: String) {
errorMessage = message
state = SigningOpState.Error(message)
@@ -87,6 +87,21 @@ fun SigningStatusBar(
}
}
is SigningOpState.Progress -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp),
) {
val label = opState.label ?: "Signing"
Text(
text = "$label (${opState.current} of ${opState.total})",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Error -> {
Snackbar(
shape = RoundedCornerShape(8.dp),