diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index 710e0cdd..cc44f475 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -576,6 +576,20 @@ class AppManager private constructor() : FfiReconcile { is AppStateReconcileMessage.WalletsChanged -> { wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() } } + + is AppStateReconcileMessage.ShowLoadingPopup -> { + alertState = + TaggedItem( + AppAlertState.General( + title = "Working on it...", + message = "", + ), + ) + } + + is AppStateReconcileMessage.HideLoadingPopup -> { + alertState = null + } } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/views/QrExportView.kt b/android/app/src/main/java/org/bitcoinppl/cove/views/QrExportView.kt index d97a2fcc..94f303aa 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/views/QrExportView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/views/QrExportView.kt @@ -52,8 +52,8 @@ import org.bitcoinppl.cove_core.types.QrExportFormat fun QrExportView( title: String, subtitle: String, - generateBbqrStrings: (QrDensity) -> List, - generateUrStrings: ((QrDensity) -> List)? = null, + generateBbqrStrings: suspend (QrDensity) -> List, + generateUrStrings: (suspend (QrDensity) -> List)? = null, modifier: Modifier = Modifier, ) { var selectedFormat by remember { mutableStateOf(QrExportFormat.BBQR) } @@ -65,7 +65,8 @@ fun QrExportView( // whether to show the format picker (only if UR is available) val showFormatPicker = generateUrStrings != null - fun generateQrCodes() { + // generate QR codes on initial load and when format/density changes + LaunchedEffect(selectedFormat, density) { try { qrStrings = when (selectedFormat) { @@ -80,11 +81,6 @@ fun QrExportView( } } - // generate QR codes on initial load and when format/density changes - LaunchedEffect(selectedFormat, density) { - generateQrCodes() - } - // animation interval: dynamic based on density for both formats val animationDelayMs = when (selectedFormat) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet/MoreInfoPopover.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet/MoreInfoPopover.kt index 2d880ed3..b68b47fe 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet/MoreInfoPopover.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet/MoreInfoPopover.kt @@ -10,21 +10,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.bitcoinppl.cove.AppAlertState import org.bitcoinppl.cove.AppManager -import org.bitcoinppl.cove.TaggedItem import org.bitcoinppl.cove.WalletManager -// delay before showing export loading alert -private const val EXPORT_LOADING_ALERT_DELAY_MS = 500L - -// delay before showing file picker after dismissing alert -private const val ALERT_DISMISS_DELAY_MS = 500L - // export type for tracking what is being exported sealed class ExportType { data object Labels : ExportType() @@ -102,58 +92,23 @@ fun rememberWalletExportLaunchers( ) { uri -> uri?.let { scope.launch { - // capture manager at coroutine start to avoid null during suspension val currentManager = manager exportState.isExporting = true val currentExportType = exportState.exportType - // track the alert by identity to avoid race conditions - var loadingAlertItem: TaggedItem? = null - try { - // show loading alert for transaction exports after a delay - val alertJob = - scope.launch { - delay(EXPORT_LOADING_ALERT_DELAY_MS) - if (exportState.isExporting && currentExportType is ExportType.Transactions) { - val alert: TaggedItem = - TaggedItem( - AppAlertState.General( - title = "Exporting, please wait...", - message = "Creating a transaction export file. If this is the first time it might take a while", - ), - ) - loadingAlertItem = alert - app.alertState = alert - } - } - + // fetch content using new async methods that handle loading popup val content = when (currentExportType) { is ExportType.Transactions -> { - withContext(Dispatchers.IO) { - currentManager?.rust?.createTransactionsWithFiatExport() - } + currentManager?.rust?.exportTransactionsCsv()?.content } is ExportType.Labels -> { - withContext(Dispatchers.IO) { - currentManager?.rust?.labelManager()?.use { it.export() } - } + currentManager?.rust?.exportLabelsForShare()?.content } null -> null } - // cancel alert job and wait for it to complete to avoid race - alertJob.cancelAndJoin() - - // clear alert only if it's still the one we set (identity check) - loadingAlertItem?.let { alert -> - if (app.alertState?.id == alert.id) { - app.alertState = null - delay(ALERT_DISMISS_DELAY_MS) - } - } - content?.let { data -> withContext(Dispatchers.IO) { context.contentResolver.openOutputStream(uri)?.use { output -> @@ -179,13 +134,6 @@ fun rememberWalletExportLaunchers( } } catch (e: Exception) { android.util.Log.e(tag, "error exporting file", e) - // clear loading alert on error only if it's still ours - loadingAlertItem?.let { alert -> - if (app.alertState?.id == alert.id) { - app.alertState = null - } - } - val errorType = when (currentExportType) { is ExportType.Transactions -> "transactions" diff --git a/android/app/src/main/java/org/bitcoinppl/cove/wallet/WalletSheets.kt b/android/app/src/main/java/org/bitcoinppl/cove/wallet/WalletSheets.kt index c2ca8632..4bc59e09 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/wallet/WalletSheets.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/wallet/WalletSheets.kt @@ -119,15 +119,14 @@ internal fun WalletSheetsHost( TextButton( onClick = { showExportLabelsDialog = false + exportLabelManager?.close() + exportLabelManager = null scope.launch { try { shareLabelsFile(context, manager) } catch (e: Exception) { android.util.Log.e(tag, "Failed to share labels", e) snackbarHostState.showSnackbar("Unable to share labels: ${e.localizedMessage ?: e.message}") - } finally { - exportLabelManager?.close() - exportLabelManager = null } } }, @@ -164,7 +163,7 @@ internal fun WalletSheetsHost( QrExportView( title = "Export Labels", subtitle = "Scan to import labels\ninto another wallet", - generateBbqrStrings = { density -> labelManager.exportToBbqrWithDensity(density) }, + generateBbqrStrings = { density -> manager.rust.exportLabelsForQr(density) }, generateUrStrings = null, modifier = Modifier.padding(16.dp), ) @@ -239,16 +238,11 @@ private suspend fun shareLabelsFile( context: Context, manager: WalletManager, ) { - withContext(Dispatchers.IO) { - val metadata = manager.walletMetadata - val labelsContent = manager.rust.labelManager().use { it.export() } - val fileName = - manager.rust.labelManager().use { lm -> - "${lm.exportDefaultFileName(metadata?.name ?: "wallet")}.jsonl" - } + val result = manager.rust.exportLabelsForShare() - val file = File(context.cacheDir, fileName) - file.writeText(labelsContent) + withContext(Dispatchers.IO) { + val file = File(context.cacheDir, result.filename) + file.writeText(result.content) val uri = FileProvider.getUriForFile( diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index e5c50a41..efdeeb95 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1431,6 +1431,12 @@ external fun uniffi_cove_checksum_method_rustwalletmanager_display_fiat_amount( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_display_sent_and_received_amount( ): Short +external fun uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_qr( +): Short +external fun uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_share( +): Short +external fun uniffi_cove_checksum_method_rustwalletmanager_export_transactions_csv( +): Short external fun uniffi_cove_checksum_method_rustwalletmanager_fee_rate_options( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_fees( @@ -2371,6 +2377,12 @@ external fun uniffi_cove_fn_method_rustwalletmanager_display_fiat_amount(`ptr`: ): RustBuffer.ByValue external fun uniffi_cove_fn_method_rustwalletmanager_display_sent_and_received_amount(`ptr`: Long,`sentAndReceived`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_cove_fn_method_rustwalletmanager_export_labels_for_qr(`ptr`: Long,`density`: Long, +): Long +external fun uniffi_cove_fn_method_rustwalletmanager_export_labels_for_share(`ptr`: Long, +): Long +external fun uniffi_cove_fn_method_rustwalletmanager_export_transactions_csv(`ptr`: Long, +): Long external fun uniffi_cove_fn_method_rustwalletmanager_fee_rate_options(`ptr`: Long, ): Long external fun uniffi_cove_fn_method_rustwalletmanager_fees(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3805,6 +3817,15 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustwalletmanager_display_sent_and_received_amount() != 41756.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_qr() != 32503.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_share() != 38081.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_export_transactions_csv() != 27705.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustwalletmanager_fee_rate_options() != 36497.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -17964,6 +17985,21 @@ public interface RustWalletManagerInterface { fun `displaySentAndReceivedAmount`(`sentAndReceived`: SentAndReceived): kotlin.String + /** + * Export labels as QR codes with conditional loading popup + */ + suspend fun `exportLabelsForQr`(`density`: QrDensity): List + + /** + * Export labels for share with conditional loading popup + */ + suspend fun `exportLabelsForShare`(): LabelExportResult + + /** + * Export transactions as CSV with conditional loading popup + */ + suspend fun `exportTransactionsCsv`(): TransactionExportResult + suspend fun `feeRateOptions`(): FeeRateOptions fun `fees`(): FeeResponse? @@ -18384,6 +18420,78 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf + /** + * Export labels as QR codes with conditional loading popup + */ + @Throws(LabelManagerException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `exportLabelsForQr`(`density`: QrDensity) : List { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_export_labels_for_qr( + uniffiHandle, + FfiConverterTypeQrDensity.lower(`density`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterSequenceString.lift(it) }, + // Error FFI converter + LabelManagerException.ErrorHandler, + ) + } + + + /** + * Export labels for share with conditional loading popup + */ + @Throws(LabelManagerException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `exportLabelsForShare`() : LabelExportResult { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_export_labels_for_share( + uniffiHandle, + + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeLabelExportResult.lift(it) }, + // Error FFI converter + LabelManagerException.ErrorHandler, + ) + } + + + /** + * Export transactions as CSV with conditional loading popup + */ + @Throws(WalletManagerException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `exportTransactionsCsv`() : TransactionExportResult { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_export_transactions_csv( + uniffiHandle, + + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeTransactionExportResult.lift(it) }, + // Error FFI converter + WalletManagerException.ErrorHandler, + ) + } + + @Throws(WalletManagerException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `feeRateOptions`() : FeeRateOptions { @@ -24732,6 +24840,44 @@ public object FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): LabelExportResult { + return LabelExportResult( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: LabelExportResult) = ( + FfiConverterString.allocationSize(value.`content`) + + FfiConverterString.allocationSize(value.`filename`) + ) + + override fun write(value: LabelExportResult, buf: ByteBuffer) { + FfiConverterString.write(value.`content`, buf) + FfiConverterString.write(value.`filename`, buf) + } +} + + + data class Node ( var `name`: kotlin.String , @@ -25274,6 +25420,44 @@ public object FfiConverterTypeTapSignerSetupComplete: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): TransactionExportResult { + return TransactionExportResult( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: TransactionExportResult) = ( + FfiConverterString.allocationSize(value.`content`) + + FfiConverterString.allocationSize(value.`filename`) + ) + + override fun write(value: TransactionExportResult, buf: ByteBuffer) { + FfiConverterString.write(value.`content`, buf) + FfiConverterString.write(value.`filename`, buf) + } +} + + + data class WalletMetadata ( var `id`: WalletId , @@ -26234,6 +26418,12 @@ sealed class AppStateReconcileMessage: Disposable { object WalletsChanged : AppStateReconcileMessage() + object ShowLoadingPopup : AppStateReconcileMessage() + + + object HideLoadingPopup : AppStateReconcileMessage() + + @Suppress("UNNECESSARY_SAFE_CALL") // codegen is much simpler if we unconditionally emit safe calls here @@ -26316,6 +26506,10 @@ sealed class AppStateReconcileMessage: Disposable { } is AppStateReconcileMessage.WalletsChanged -> {// Nothing to destroy } + is AppStateReconcileMessage.ShowLoadingPopup -> {// Nothing to destroy + } + is AppStateReconcileMessage.HideLoadingPopup -> {// Nothing to destroy + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -26367,6 +26561,8 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< ) 12 -> AppStateReconcileMessage.AcceptedTerms 13 -> AppStateReconcileMessage.WalletsChanged + 14 -> AppStateReconcileMessage.ShowLoadingPopup + 15 -> AppStateReconcileMessage.HideLoadingPopup else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -26461,6 +26657,18 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< 4UL ) } + is AppStateReconcileMessage.ShowLoadingPopup -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is AppStateReconcileMessage.HideLoadingPopup -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } } override fun write(value: AppStateReconcileMessage, buf: ByteBuffer) { @@ -26528,6 +26736,14 @@ public object FfiConverterTypeAppStateReconcileMessage : FfiConverterRustBuffer< buf.putInt(13) Unit } + is AppStateReconcileMessage.ShowLoadingPopup -> { + buf.putInt(14) + Unit + } + is AppStateReconcileMessage.HideLoadingPopup -> { + buf.putInt(15) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index 06540f81..98218b9e 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -1,3 +1,4 @@ +import MijickPopups import Observation import SwiftUI @@ -273,6 +274,12 @@ private let walletModeChangeDelayMs = 250 case .walletsChanged: wallets = (try? database.wallets().all()) ?? [] + + case .showLoadingPopup: + Task { await MiddlePopup(state: .loading).present() } + + case .hideLoadingPopup: + Task { await PopupStack.dismissAllPopups() } } } } diff --git a/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift b/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift index 87b92511..4f1e9772 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/MoreInfoPopover.swift @@ -5,17 +5,8 @@ // Created by Praveen Perera on 2/11/25. // -import MijickPopups import SwiftUI -private class LoadingState { - var popupWasShown = false - var popupShownAt: Date? -} - -private let loadingPopupDelay: Duration = .milliseconds(250) -private let minimumPopupDisplayTime: TimeInterval = 0.4 - struct MoreInfoPopover: View { @Environment(AppManager.self) private var app @@ -26,10 +17,6 @@ struct MoreInfoPopover: View { // bindings @Binding var showExportLabelsConfirmation: Bool - // state - @State private var showLoadingTask: Task? - @State private var exportTask: Task? - private var hasLabels: Bool { labelManager.hasLabels() } @@ -51,84 +38,19 @@ struct MoreInfoPopover: View { } func exportTransactions() { - performExport( - operation: { - let csv = try await manager.rust.createTransactionsWithFiatExport() - let filename = "\(metadata.name.lowercased())_transactions.csv" - return (csv, filename) - }, - errorTitle: "Transaction Export Failed", - errorPrefix: "Unable to export transactions" - ) - } - - private func performExport( - operation: @escaping () async throws -> (data: String, filename: String), - errorTitle: String, - errorPrefix: String - ) { - let loadingState = LoadingState() - - // start delayed loading task - showLoadingTask = Task { @MainActor in - try? await Task.sleep(for: loadingPopupDelay) - if Task.isCancelled { return } - - loadingState.popupWasShown = true - loadingState.popupShownAt = Date.now - await MiddlePopup(state: .loading).present() - } - - // start export operation - exportTask = Task { + Task { do { - let (data, filename) = try await operation() - - // cancel loading if not shown yet - showLoadingTask?.cancel() - - // check if cancelled before continuing - if Task.isCancelled { return } - - // if popup was shown, ensure minimum display time - if loadingState.popupWasShown, let shownAt = loadingState.popupShownAt { - let elapsed = Date.now.timeIntervalSince(shownAt) - let remaining = max(0, minimumPopupDisplayTime - elapsed) - - if remaining > 0 { - try? await Task.sleep(for: .seconds(remaining)) - } - - await dismissAllPopups() - } - - // check if cancelled before showing share sheet - if Task.isCancelled { return } - - // show ShareSheet - await MainActor.run { - ShareSheet.present(data: data, filename: filename) { success in - if !success { - Log.warn("\(errorTitle): cancelled or failed") - } + let result = try await manager.rust.exportTransactionsCsv() + ShareSheet.present(data: result.content, filename: result.filename) { success in + if !success { + Log.warn("Transaction Export Failed: cancelled or failed") } } } catch { - showLoadingTask?.cancel() - - // don't show error if cancelled - if Task.isCancelled { return } - - await MainActor.run { - if loadingState.popupWasShown { - Task { await dismissAllPopups() } - } - - app.alertState = .init(.general( - title: errorTitle, - message: "\(errorPrefix): \(error.localizedDescription)" - )) - } + app.alertState = .init(.general( + title: "Transaction Export Failed", + message: "Unable to export transactions: \(error.localizedDescription)" + )) } } } @@ -206,10 +128,6 @@ struct MoreInfoPopover: View { } } .tint(.primary) - .onDisappear { - showLoadingTask?.cancel() - exportTask?.cancel() - } } } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift index 36afe453..9318d58f 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletScreen.swift @@ -154,12 +154,15 @@ struct SelectedWalletScreen: View { sheetState = TaggedItem(.receive) } + func showQrExport() { + showLabelsQrExport = true + } + func shareLabelsFile() { Task { do { - let content = try labelManager.export() - let filename = "\(labelManager.exportDefaultFileName(name: metadata.name)).jsonl" - ShareSheet.present(data: content, filename: filename) { success in + let result = try await manager.rust.exportLabelsForShare() + ShareSheet.present(data: result.content, filename: result.filename) { success in if !success { Log.warn("Label Export Failed: cancelled or failed") } @@ -226,7 +229,7 @@ struct SelectedWalletScreen: View { isPresented: $showExportLabelsConfirmation ) { Button("QR Code") { - showLabelsQrExport = true + showQrExport() } Button("Share...") { @@ -292,7 +295,7 @@ struct SelectedWalletScreen: View { title: "Export Labels", subtitle: "Scan to import labels\ninto another wallet", generateBbqrStrings: { density in - try labelManager.exportToBbqrWithDensity(density: density) + try await manager.rust.exportLabelsForQr(density: density) }, generateUrStrings: nil ) diff --git a/ios/Cove/Views/QrExportView.swift b/ios/Cove/Views/QrExportView.swift index 99ea856f..bc601089 100644 --- a/ios/Cove/Views/QrExportView.swift +++ b/ios/Cove/Views/QrExportView.swift @@ -15,8 +15,8 @@ extension QrExportFormat: CaseIterable { struct QrExportView: View { let title: String let subtitle: String - let generateBbqrStrings: (QrDensity) throws -> [String] - let generateUrStrings: ((QrDensity) throws -> [String])? + let generateBbqrStrings: (QrDensity) async throws -> [String] + let generateUrStrings: ((QrDensity) async throws -> [String])? @State private var selectedFormat: QrExportFormat = .bbqr @State private var density: QrDensity = .init() @@ -64,13 +64,13 @@ struct QrExportView: View { QrContent } .onChange(of: selectedFormat) { _, _ in - generateQrCodes() + Task { await generateQrCodes() } } .onChange(of: density) { _, _ in - generateQrCodes() + Task { await generateQrCodes() } } - .onAppear { - generateQrCodes() + .task { + await generateQrCodes() } } @@ -185,17 +185,17 @@ struct QrExportView: View { .cornerRadius(50) } - func generateQrCodes() { + func generateQrCodes() async { do { let strings: [String] = switch selectedFormat { case .bbqr: - try generateBbqrStrings(density) + try await generateBbqrStrings(density) case .ur: if let generateUrStrings { - try generateUrStrings(density) + try await generateUrStrings(density) } else { // fallback to BBQr if UR not available - try generateBbqrStrings(density) + try await generateBbqrStrings(density) } } qrs = strings.map { QrCodeView(text: $0) } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index c56bb72b..bb11bd1c 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -4660,14 +4660,14 @@ public protocol LabelManagerProtocol: AnyObject, Sendable { func deleteLabelsForTxn(txId: TxId) throws - func export() throws -> String + func export() async throws -> String func exportDefaultFileName(name: String) -> String /** * Export labels as BBQr-encoded QR strings for animated display */ - func exportToBbqrWithDensity(density: QrDensity) throws -> [String] + func exportToBbqrWithDensity(density: QrDensity) async throws -> [String] func hasLabels() -> Bool @@ -4749,12 +4749,21 @@ open func deleteLabelsForTxn(txId: TxId)throws {try rustCallWithError(FfiConve } } -open func export()throws -> String { - return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeLabelManagerError_lift) { - uniffi_cove_fn_method_labelmanager_export( - self.uniffiCloneHandle(),$0 - ) -}) +open func export()async throws -> String { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_labelmanager_export( + self.uniffiCloneHandle() + + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterString.lift, + errorHandler: FfiConverterTypeLabelManagerError_lift + ) } open func exportDefaultFileName(name: String) -> String { @@ -4769,13 +4778,21 @@ open func exportDefaultFileName(name: String) -> String { /** * Export labels as BBQr-encoded QR strings for animated display */ -open func exportToBbqrWithDensity(density: QrDensity)throws -> [String] { - return try FfiConverterSequenceString.lift(try rustCallWithError(FfiConverterTypeLabelManagerError_lift) { - uniffi_cove_fn_method_labelmanager_export_to_bbqr_with_density( - self.uniffiCloneHandle(), - FfiConverterTypeQrDensity_lower(density),$0 - ) -}) +open func exportToBbqrWithDensity(density: QrDensity)async throws -> [String] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_labelmanager_export_to_bbqr_with_density( + self.uniffiCloneHandle(), + FfiConverterTypeQrDensity_lower(density) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceString.lift, + errorHandler: FfiConverterTypeLabelManagerError_lift + ) } open func hasLabels() -> Bool { @@ -7593,6 +7610,21 @@ public protocol RustWalletManagerProtocol: AnyObject, Sendable { func displaySentAndReceivedAmount(sentAndReceived: SentAndReceived) -> String + /** + * Export labels as QR codes with conditional loading popup + */ + func exportLabelsForQr(density: QrDensity) async throws -> [String] + + /** + * Export labels for share with conditional loading popup + */ + func exportLabelsForShare() async throws -> LabelExportResult + + /** + * Export transactions as CSV with conditional loading popup + */ + func exportTransactionsCsv() async throws -> TransactionExportResult + func feeRateOptions() async throws -> FeeRateOptions func fees() -> FeeResponse? @@ -7944,6 +7976,66 @@ open func displaySentAndReceivedAmount(sentAndReceived: SentAndReceived) -> Stri }) } + /** + * Export labels as QR codes with conditional loading popup + */ +open func exportLabelsForQr(density: QrDensity)async throws -> [String] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_rustwalletmanager_export_labels_for_qr( + self.uniffiCloneHandle(), + FfiConverterTypeQrDensity_lower(density) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceString.lift, + errorHandler: FfiConverterTypeLabelManagerError_lift + ) +} + + /** + * Export labels for share with conditional loading popup + */ +open func exportLabelsForShare()async throws -> LabelExportResult { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_rustwalletmanager_export_labels_for_share( + self.uniffiCloneHandle() + + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeLabelExportResult_lift, + errorHandler: FfiConverterTypeLabelManagerError_lift + ) +} + + /** + * Export transactions as CSV with conditional loading popup + */ +open func exportTransactionsCsv()async throws -> TransactionExportResult { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_rustwalletmanager_export_transactions_csv( + self.uniffiCloneHandle() + + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeTransactionExportResult_lift, + errorHandler: FfiConverterTypeWalletManagerError_lift + ) +} + open func feeRateOptions()async throws -> FeeRateOptions { return try await uniffiRustCallAsync( @@ -11908,6 +12000,60 @@ public func FfiConverterTypeInternalOnlyMetadata_lower(_ value: InternalOnlyMeta } +public struct LabelExportResult: Equatable, Hashable { + public var content: String + public var filename: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(content: String, filename: String) { + self.content = content + self.filename = filename + } + + + + +} + +#if compiler(>=6) +extension LabelExportResult: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeLabelExportResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LabelExportResult { + return + try LabelExportResult( + content: FfiConverterString.read(from: &buf), + filename: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: LabelExportResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.content, into: &buf) + FfiConverterString.write(value.filename, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLabelExportResult_lift(_ buf: RustBuffer) throws -> LabelExportResult { + return try FfiConverterTypeLabelExportResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLabelExportResult_lower(_ value: LabelExportResult) -> RustBuffer { + return FfiConverterTypeLabelExportResult.lower(value) +} + + public struct Node: Equatable, Hashable { public var name: String public var network: Network @@ -12563,6 +12709,60 @@ public func FfiConverterTypeTapSignerSetupComplete_lower(_ value: TapSignerSetup } +public struct TransactionExportResult: Equatable, Hashable { + public var content: String + public var filename: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(content: String, filename: String) { + self.content = content + self.filename = filename + } + + + + +} + +#if compiler(>=6) +extension TransactionExportResult: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTransactionExportResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TransactionExportResult { + return + try TransactionExportResult( + content: FfiConverterString.read(from: &buf), + filename: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: TransactionExportResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.content, into: &buf) + FfiConverterString.write(value.filename, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTransactionExportResult_lift(_ buf: RustBuffer) throws -> TransactionExportResult { + return try FfiConverterTypeTransactionExportResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTransactionExportResult_lower(_ value: TransactionExportResult) -> RustBuffer { + return FfiConverterTypeTransactionExportResult.lower(value) +} + + public struct WalletMetadata: Equatable, Hashable { public var id: WalletId public var name: String @@ -13296,6 +13496,8 @@ public enum AppStateReconcileMessage { ) case acceptedTerms case walletsChanged + case showLoadingPopup + case hideLoadingPopup @@ -13353,6 +13555,10 @@ public struct FfiConverterTypeAppStateReconcileMessage: FfiConverterRustBuffer { case 13: return .walletsChanged + case 14: return .showLoadingPopup + + case 15: return .hideLoadingPopup + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -13423,6 +13629,14 @@ public struct FfiConverterTypeAppStateReconcileMessage: FfiConverterRustBuffer { case .walletsChanged: writeInt(&buf, Int32(13)) + + case .showLoadingPopup: + writeInt(&buf, Int32(14)) + + + case .hideLoadingPopup: + writeInt(&buf, Int32(15)) + } } } @@ -28506,13 +28720,13 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_labelmanager_delete_labels_for_txn() != 50691) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_labelmanager_export() != 42115) { + if (uniffi_cove_checksum_method_labelmanager_export() != 24203) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_labelmanager_export_default_file_name() != 28880) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_labelmanager_export_to_bbqr_with_density() != 31085) { + if (uniffi_cove_checksum_method_labelmanager_export_to_bbqr_with_density() != 50284) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_labelmanager_has_labels() != 29517) { @@ -28764,6 +28978,15 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustwalletmanager_display_sent_and_received_amount() != 41756) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_qr() != 32503) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_rustwalletmanager_export_labels_for_share() != 38081) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_rustwalletmanager_export_transactions_csv() != 27705) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustwalletmanager_fee_rate_options() != 36497) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/src/app/reconcile.rs b/rust/src/app/reconcile.rs index d66d9058..a9f10463 100644 --- a/rust/src/app/reconcile.rs +++ b/rust/src/app/reconcile.rs @@ -31,6 +31,8 @@ pub enum AppStateReconcileMessage { PushedRoute(Route), AcceptedTerms, WalletsChanged, + ShowLoadingPopup, + HideLoadingPopup, } // alias for easier imports on the rust side @@ -57,7 +59,9 @@ impl Updater { } pub fn send_update(message: AppStateReconcileMessage) { - Self::global().0.send(message).expect("failed to send update"); + if let Err(e) = Self::global().0.send(message) { + tracing::error!("Failed to send update, frontend may be disconnected: {e}"); + } } } diff --git a/rust/src/label_manager.rs b/rust/src/label_manager.rs index 49b4f9f0..5548257c 100644 --- a/rust/src/label_manager.rs +++ b/rust/src/label_manager.rs @@ -239,17 +239,23 @@ impl LabelManager { self.import_labels(labels) } - pub fn export(&self) -> Result { - let labels = - self.db.labels.all_labels().map_err(|e| LabelManagerError::Get(e.to_string()))?; + pub async fn export(&self) -> Result { + let db = self.db.clone(); + + crate::task::spawn_blocking(move || { + let labels = + db.labels.all_labels().map_err(|e| LabelManagerError::Get(e.to_string()))?; - let labels = labels.export().map_err(|e| LabelManagerError::Export(e.to_string()))?; + let labels = labels.export().map_err(|e| LabelManagerError::Export(e.to_string()))?; - Ok(labels) + Ok(labels) + }) + .await + .map_err(|e| LabelManagerError::Export(e.to_string()))? } /// Export labels as BBQr-encoded QR strings for animated display - pub fn export_to_bbqr_with_density( + pub async fn export_to_bbqr_with_density( &self, density: &QrDensity, ) -> Result, LabelManagerError> { @@ -260,25 +266,30 @@ impl LabelManager { split::{Split, SplitOptions}, }; - let labels_jsonl = self.export()?; - let data = labels_jsonl.as_bytes(); - - let version = Version::try_from(density.bbqr_max_version()).unwrap_or(Version::V15); - - let split = Split::try_from_data( - data, - FileType::Json, - SplitOptions { - encoding: Encoding::Zlib, - min_split_number: 1, - max_split_number: 100, - min_version: Version::V01, - max_version: version, - }, - ) - .map_err(|e| LabelManagerError::Export(format!("BBQr encoding failed: {e}")))?; - - Ok(split.parts) + let labels_jsonl = self.export().await?; + let max_version = density.bbqr_max_version(); + + crate::task::spawn_blocking(move || { + let data = labels_jsonl.as_bytes(); + let version = Version::try_from(max_version).unwrap_or(Version::V15); + + let split = Split::try_from_data( + data, + FileType::Json, + SplitOptions { + encoding: Encoding::Zlib, + min_split_number: 1, + max_split_number: 100, + min_version: Version::V01, + max_version: version, + }, + ) + .map_err(|e| LabelManagerError::Export(format!("BBQr encoding failed: {e}")))?; + + Ok(split.parts) + }) + .await + .map_err(|e| LabelManagerError::Export(e.to_string()))? } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5a0f4b91..bceb528a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,6 +15,7 @@ mod hardware_export; mod historical_price_service; mod keys; mod label_manager; +mod loading_popup; mod manager; mod mnemonic; mod multi_format; diff --git a/rust/src/loading_popup.rs b/rust/src/loading_popup.rs new file mode 100644 index 00000000..47db840a --- /dev/null +++ b/rust/src/loading_popup.rs @@ -0,0 +1,46 @@ +//! Conditional loading popup helper for async operations + +use std::future::Future; + +use tokio::time::{Duration, Instant, sleep}; + +use crate::app::reconcile::{Update, Updater}; + +const LOADING_POPUP_DELAY_MS: u64 = 50; +const MINIMUM_POPUP_DISPLAY_MS: u64 = 350; + +/// Runs an async operation with conditional loading popup +pub async fn with_loading_popup(operation: F) -> Result +where + F: Future>, +{ + use tokio::pin; + pin!(operation); + + let mut popup_shown_at: Option = None; + + // biased checks operation completion first, avoiding popup for fast operations + let result = tokio::select! { + biased; + + result = &mut operation => result, + + _ = sleep(Duration::from_millis(LOADING_POPUP_DELAY_MS)) => { + Updater::send_update(Update::ShowLoadingPopup); + popup_shown_at = Some(Instant::now()); + operation.await + } + }; + + if let Some(shown_at) = popup_shown_at { + let elapsed = shown_at.elapsed(); + let min_display = Duration::from_millis(MINIMUM_POPUP_DISPLAY_MS); + + if elapsed < min_display { + sleep(min_display - elapsed).await; + } + Updater::send_update(Update::HideLoadingPopup); + } + + result +} diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index 91d73611..c6d9c963 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -24,7 +24,8 @@ use crate::{ client::{FIAT_CLIENT, PriceResponse}, }, keychain::{Keychain, KeychainError}, - label_manager::LabelManager, + label_manager::{LabelManager, LabelManagerError}, + loading_popup::with_loading_popup, psbt::Psbt, reporting::HistoricalFiatPriceReport, router::Route, @@ -46,7 +47,7 @@ use crate::{ use cove_types::{ address::AddressInfoWithDerivation, - confirm::{ConfirmDetails, SplitOutput}, + confirm::{ConfirmDetails, QrDensity, SplitOutput}, }; use cove_types::{confirm::AddressAndAmount, fees::FeeRateOptions}; @@ -119,6 +120,18 @@ pub enum SendFlowErrorAlert { ConfirmDetails(String), } +#[derive(Debug, Clone, uniffi::Record)] +pub struct LabelExportResult { + pub content: String, + pub filename: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct TransactionExportResult { + pub content: String, + pub filename: String, +} + #[uniffi::export(callback_interface)] pub trait WalletManagerReconciler: Send + Sync + std::fmt::Debug + 'static { fn reconcile(&self, message: Message); @@ -386,6 +399,66 @@ impl RustWalletManager { Ok(csv.into_string()) } + /// Export labels for share with conditional loading popup + #[uniffi::method] + pub async fn export_labels_for_share(&self) -> Result { + let lm = self.label_manager.clone(); + let name = self.metadata.read().name.clone(); + + with_loading_popup(async move { + let content = lm.export().await?; + let filename = format!("{}.jsonl", lm.export_default_file_name(name)); + Ok(LabelExportResult { content, filename }) + }) + .await + } + + /// Export labels as QR codes with conditional loading popup + #[uniffi::method] + pub async fn export_labels_for_qr( + &self, + density: Arc, + ) -> Result, LabelManagerError> { + let lm = self.label_manager.clone(); + + with_loading_popup(async move { lm.export_to_bbqr_with_density(&density).await }).await + } + + /// Export transactions as CSV with conditional loading popup + #[uniffi::method] + pub async fn export_transactions_csv(&self) -> Result { + let name = self.metadata.read().name.clone(); + let actor = self.actor.clone(); + + with_loading_popup(async move { + let txns_with_prices = call!(actor.txns_with_prices()) + .await + .map_err(|e| Error::TransactionsRetrievalError(e.to_string()))? + .map_err(|e| Error::GetHistoricalPricesError(e.to_string()))?; + + crate::task::spawn_blocking(move || { + let fiat_currency = + Database::global().global_config.fiat_currency().unwrap_or_default(); + let report = HistoricalFiatPriceReport::new(fiat_currency, txns_with_prices); + let csv = report.create_csv().map_err_str(Error::CsvCreationError)?; + + let sanitized_name = name + .replace(' ', "_") + .replace(|c: char| !c.is_alphanumeric() && c != '_', "") + .to_ascii_lowercase(); + + let sanitized_name = + if sanitized_name.is_empty() { "wallet".to_string() } else { sanitized_name }; + + let filename = format!("{sanitized_name}_transactions.csv"); + Ok(TransactionExportResult { content: csv.into_string(), filename }) + }) + .await + .map_err(|e| Error::CsvCreationError(e.to_string()))? + }) + .await + } + #[uniffi::method] pub async fn first_address(&self) -> Result { let address_info = call!(self.actor.address_at(0)) diff --git a/rust/src/task.rs b/rust/src/task.rs index be340a0d..fbe8367b 100644 --- a/rust/src/task.rs +++ b/rust/src/task.rs @@ -47,6 +47,14 @@ where handle.block_on(task) } +pub fn spawn_blocking(f: F) -> JoinHandle +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + TOKIO.get().expect("tokio runtime not initalized").spawn_blocking(f) +} + /// Provides an infallible way to spawn an actor onto the Tokio runtime, /// equivalent to `Addr::new`. pub fn spawn_actor(actor: T) -> Addr {