refactor: fix loading flash

This commit is contained in:
2026-06-10 17:32:50 +02:00
parent f9ddebac8a
commit 9ba408e157
4 changed files with 74 additions and 54 deletions
@@ -490,7 +490,7 @@ fun WalletScreen(
budgets = state.budgets, budgets = state.budgets,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onRevoke = { onRevoke = {
nwcVm.revokeConnection(pubkey) nwcVm.deleteConnection(pubkey)
navController.popBackStack() navController.popBackStack()
} }
) )
@@ -15,6 +15,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@Composable @Composable
@@ -43,7 +44,9 @@ internal fun NwcConnectionRow(
// threshold, even though confirmValueChange returns false and currentValue // threshold, even though confirmValueChange returns false and currentValue
// stays at Settled. Use a flag so we only fire once per swipe gesture. // stays at Settled. Use a flag so we only fire once per swipe gesture.
var dialogTriggered by remember { mutableStateOf(false) } var dialogTriggered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Fires when the swipe crosses the threshold — trigger confirm dialog
LaunchedEffect(dismissState.targetValue) { LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting && !isDeleting
@@ -51,12 +54,14 @@ internal fun NwcConnectionRow(
) { ) {
dialogTriggered = true dialogTriggered = true
onDeleteConfirm() onDeleteConfirm()
// Snap the row back to its resting position. // Reset in its own scope — won't be cancelled when targetValue changes back
dismissState.reset() scope.launch { dismissState.reset() }
} }
// Reset the flag once the swipe returns to Settled so the }
// gesture can be triggered again if the user cancels and re-swipes.
if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) { // Fires when the row has fully snapped back — re-arm for next swipe
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == SwipeToDismissBoxValue.Settled) {
dialogTriggered = false dialogTriggered = false
} }
} }
@@ -14,8 +14,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
// ── Screen ────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NwcScreen( fun NwcScreen(
@@ -27,9 +25,11 @@ fun NwcScreen(
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle() val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle() val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle() val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
val showAddSheet by viewModel.showAddSheet.collectAsStateWithLifecycle()
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
var showAddSheet by remember { mutableStateOf(false) } // ── Shared refresh lambda (item #13) ──────────────────────────────────────
var pendingDeletePubkey by remember { mutableStateOf<String?>(null) } val refresh = { viewModel.loadConnections(includeExpired = true) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -43,7 +43,7 @@ fun NwcScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { showAddSheet = true }) { FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add connection") Icon(Icons.Default.Add, contentDescription = "Add connection")
} }
} }
@@ -53,7 +53,7 @@ fun NwcScreen(
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { viewModel.loadConnections(includeExpired = true) }, onRefresh = refresh, // ← was loadConnections(includeExpired = true)
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
@@ -68,7 +68,7 @@ fun NwcScreen(
is NwcUiState.Error -> { is NwcUiState.Error -> {
NwcErrorContent( NwcErrorContent(
message = state.message, message = state.message,
onRetry = { viewModel.loadConnections(includeExpired = true) }, onRetry = refresh, // ← was loadConnections(includeExpired = true)
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -76,7 +76,7 @@ fun NwcScreen(
is NwcUiState.Success -> { is NwcUiState.Success -> {
if (state.connections.isEmpty()) { if (state.connections.isEmpty()) {
NwcEmptyContent( NwcEmptyContent(
onAddNew = { showAddSheet = true }, onAddNew = { viewModel.openAddSheet() },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else { } else {
@@ -91,7 +91,7 @@ fun NwcScreen(
NwcConnectionRow( NwcConnectionRow(
connection, connection,
isDeleting, isDeleting,
onDeleteConfirm = { pendingDeletePubkey = connection.data.pubkey }, onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
onConnectionClick = { onConnectionClick(connection.data.pubkey) } onConnectionClick = { onConnectionClick(connection.data.pubkey) }
) )
HorizontalDivider( HorizontalDivider(
@@ -117,9 +117,9 @@ fun NwcScreen(
expiresAt = expiresAt, expiresAt = expiresAt,
budgets = budgets budgets = budgets
) )
showAddSheet = false viewModel.closeAddSheet() // ← was openAddSheet()
}, },
onDismiss = { showAddSheet = false } onDismiss = { viewModel.closeAddSheet() } // ← was openAddSheet()
) )
} }
@@ -132,21 +132,18 @@ fun NwcScreen(
} }
// ── Delete confirmation dialog ───────────────────────────────────────────── // ── Delete confirmation dialog ─────────────────────────────────────────────
pendingDeletePubkey?.let { pubkey -> pendingDeletePubkey?.let {
AlertDialog( AlertDialog(
onDismissRequest = { pendingDeletePubkey = null }, onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Remove connection?") }, title = { Text("Remove connection?") },
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") }, text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = { viewModel.confirmDelete() } // ← was deleteConnection(pubkey) + cancelDelete()
viewModel.deleteConnection(pubkey)
pendingDeletePubkey = null
}
) { Text("Remove", color = MaterialTheme.colorScheme.error) } ) { Text("Remove", color = MaterialTheme.colorScheme.error) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { pendingDeletePubkey = null }) { Text("Cancel") } TextButton(onClick = { viewModel.cancelDelete() }) { Text("Cancel") }
} }
) )
} }
@@ -65,6 +65,25 @@ class NwcViewModel(
val connectionDetailState: StateFlow<ConnectionDetailState> = val connectionDetailState: StateFlow<ConnectionDetailState> =
_connectionDetailState.asStateFlow() _connectionDetailState.asStateFlow()
// ── Sheet / dialog UI state (survives config change) ────────────────────────
private val _showAddSheet = MutableStateFlow(false)
val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow()
private val _pendingDeletePubkey = MutableStateFlow<String?>(null)
val pendingDeletePubkey: StateFlow<String?> = _pendingDeletePubkey.asStateFlow()
fun openAddSheet() { _showAddSheet.value = true }
fun closeAddSheet() { _showAddSheet.value = false }
fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey }
fun cancelDelete() { _pendingDeletePubkey.value = null }
// confirmDelete() calls the existing deleteConnection() then clears the pending key
fun confirmDelete() {
_pendingDeletePubkey.value?.let { deleteConnection(it) }
_pendingDeletePubkey.value = null
}
init { init {
loadConnections() loadConnections()
} }
@@ -134,7 +153,7 @@ class NwcViewModel(
} }
.onSuccess { url -> .onSuccess { url ->
_pairingUrl.value = url _pairingUrl.value = url
loadConnections() silentRefresh()
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] createConnection failed") Timber.e(e, "[$TAG] createConnection failed")
@@ -152,8 +171,13 @@ class NwcViewModel(
_pairingUrl.value = null _pairingUrl.value = null
} }
// ── Delete ──────────────────────────────────────────────────────────────── // ── Delete / Revoke ──────────────────────────────────────────────────────────
/**
* Deletes an NWC key by pubkey. Used by both the list screen (swipe-to-delete)
* and the detail screen (revoke button). Sets [_isDeleting] for the duration
* so the list screen can disable row interactions while in flight.
*/
fun deleteConnection(pubkey: String) { fun deleteConnection(pubkey: String) {
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}") Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch { viewModelScope.launch {
@@ -168,7 +192,7 @@ class NwcViewModel(
current.connections.filter { it.data.pubkey != pubkey } current.connections.filter { it.data.pubkey != pubkey }
) )
} }
loadConnections() silentRefresh()
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}") Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
@@ -216,34 +240,28 @@ class NwcViewModel(
_connectionDetailState.value = ConnectionDetailState.Loading _connectionDetailState.value = ConnectionDetailState.Loading
} }
// ── Silent refresh (no Loading flash) ───────────────────────────────────────
/** /**
* Deletes the key via the repo, then refreshes the list so the revoked * Syncs the connection list from the server without transitioning to
* entry disappears from NwcScreen when the user pops back. * [NwcUiState.Loading] first. Used after mutations (create, delete) where
* the UI already shows the correct optimistic state.
* Failures are non-fatal — the optimistic state is already correct.
*/ */
fun revokeConnection(pubkey: String) { private fun silentRefresh(includeExpired: Boolean = false) {
Timber.d("[$TAG] revokeConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch { viewModelScope.launch {
runCatching { repo.deleteNwcKey(pubkey) } runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { .onSuccess { keys ->
Timber.i("[$TAG] revoked connection pubkey=${pubkey.take(16)}… ✓") Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)")
// Optimistic local removal _uiState.value = NwcUiState.Success(keys)
val current = _uiState.value
if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success(
current.connections.filter { it.data.pubkey != pubkey }
)
}
loadConnections()
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] failed to revoke connection") Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to revoke connection"
)
} }
} }
} }
// ── Factory ─────────────────────────────────────────────────────────────── // ── Factory ───────────────────────────────────────────────────────────────
class Factory(private val repo: WalletRepository) : class Factory(private val repo: WalletRepository) :