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,22 +14,22 @@ 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(
viewModel : NwcViewModel, viewModel : NwcViewModel,
onBack : () -> Unit, onBack : () -> Unit,
onConnectionClick : (pubkey: String) -> Unit onConnectionClick : (pubkey: String) -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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,13 +76,13 @@ 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 {
LazyColumn( LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items( items(
items = state.connections, items = state.connections,
@@ -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) :