diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/TapSignerFlow/TapSignerSetupRetryView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/TapSignerFlow/TapSignerSetupRetryView.kt index 82159897a..598776101 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/TapSignerFlow/TapSignerSetupRetryView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/TapSignerFlow/TapSignerSetupRetryView.kt @@ -9,15 +9,24 @@ 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,10 +43,6 @@ import org.bitcoinppl.cove_core.AppAlertState import org.bitcoinppl.cove_core.SetupCmdResponse import org.bitcoinppl.cove_core.TapSignerRoute -/** - * setup retry screen - * displays when setup encounters an error but can be retried - */ @Composable fun TapSignerSetupRetryView( app: AppManager, @@ -45,9 +50,32 @@ fun TapSignerSetupRetryView( tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner, response: SetupCmdResponse, modifier: Modifier = Modifier, +) { + val availableBackup: ByteArray? = + when (response) { + is SetupCmdResponse.ContinueFromBackup -> response.v1.backup + is SetupCmdResponse.ContinueFromDerive -> response.v1.backup + else -> null + } + + if (availableBackup != null) { + SaveBackupBody(app, manager, tapSigner, response, availableBackup, modifier) + } else { + ErrorBody(app, manager, tapSigner, response, modifier) + } +} + +@Composable +private fun ErrorBody( + app: AppManager, + manager: TapSignerManager, + tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner, + response: SetupCmdResponse, + modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val context = LocalContext.current + var isRunning by remember { mutableStateOf(false) } Column( modifier = @@ -56,7 +84,6 @@ fun TapSignerSetupRetryView( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.SpaceBetween, ) { - // cancel button Row( modifier = Modifier @@ -69,7 +96,6 @@ fun TapSignerSetupRetryView( } } - // main content Column( modifier = Modifier @@ -92,14 +118,14 @@ fun TapSignerSetupRetryView( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Setup Incomplete", + text = "Could not complete setup", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, ) Text( text = - "The setup process was interrupted. You can retry to continue where you left off.", + "Please try again and hold your TAPSIGNER steady until setup is complete.", style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), @@ -107,69 +133,209 @@ fun TapSignerSetupRetryView( } } - // retry button Button( onClick = { scope.launch { - val activity = context.findActivity() - if (activity == null) { - app.alertState = - TaggedItem( - AppAlertState.General( - title = "Error", - message = "Unable to access NFC. Please try again.", - ), - ) - return@launch - } + runContinueSetup(app, manager, tapSigner, response, context, isRunning = isRunning, setRunning = { isRunning = it }) + } + }, + enabled = !isRunning, + modifier = Modifier.fillMaxWidth().padding(bottom = 30.dp), + ) { + Text("Retry") + } + } +} - val nfc = manager.getOrCreateNfc(tapSigner) +@Composable +private fun SaveBackupBody( + app: AppManager, + manager: TapSignerManager, + tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner, + response: SetupCmdResponse, + backup: ByteArray, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var isRunning by remember { mutableStateOf(false) } + val createBackupLauncher = rememberBackupExportLauncher(app) { backup } - // set up message callback for progress updates - val nfcManager = TapCardNfcManager.getInstance() - nfcManager.onMessageUpdate = { message -> - manager.scanMessage = message - } - nfcManager.onTagDetected = { manager.isTagDetected = true } - - manager.scanMessage = "Hold your phone near the TapSigner to continue setup" - manager.isTagDetected = false - manager.isScanning = true - - try { - val result = nfc.continueSetup(response) - manager.isScanning = false - manager.isTagDetected = false - nfcManager.onMessageUpdate = null - nfcManager.onTagDetected = null - - when (result) { - is SetupCmdResponse.Complete -> { - manager.resetRoute(TapSignerRoute.SetupSuccess(tapSigner, result.v1)) - } - else -> { - manager.resetRoute(TapSignerRoute.SetupRetry(tapSigner, result)) - } - } - } catch (e: Exception) { - manager.isScanning = false - manager.isTagDetected = false - nfcManager.onMessageUpdate = null - nfcManager.onTagDetected = null - - app.sheetState = null - app.alertState = - TaggedItem( - AppAlertState.TapSignerSetupFailed( - e.message ?: "Unknown error", - ), - ) + LaunchedEffect(Unit) { + app.saveTapSignerBackup(tapSigner, backup) + } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalArrangement = Arrangement.Start, + ) { + TextButton(onClick = { app.sheetState = null }) { + Text("Cancel", fontWeight = FontWeight.SemiBold) + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = "Almost there", + modifier = Modifier.size(100.dp), + tint = Color(0xFFFF9800), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Almost there", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + + Text( + text = + "Your TAPSIGNER backup was created successfully, but setup didn't fully complete. Please download your backup now, then continue to finish setup.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + Surface( + onClick = { + val fileName = "${tapSigner.identFileNamePrefix()}_backup.txt" + createBackupLauncher.launch(fileName) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Download Backup", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + + Text( + text = "You need this backup to restore your wallet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Next", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Button( + onClick = { + scope.launch { + runContinueSetup(app, manager, tapSigner, response, context, isRunning = isRunning, setRunning = { isRunning = it }) } }, + enabled = !isRunning, modifier = Modifier.fillMaxWidth().padding(bottom = 30.dp), ) { - Text("Retry Setup") + Text("Continue") + } + } +} + +private suspend fun runContinueSetup( + app: AppManager, + manager: TapSignerManager, + tapSigner: org.bitcoinppl.cove_core.tapcard.TapSigner, + response: SetupCmdResponse, + context: android.content.Context, + isRunning: Boolean, + setRunning: (Boolean) -> Unit, +) { + if (isRunning) return + setRunning(true) + + val activity = context.findActivity() + if (activity == null) { + setRunning(false) + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Error", + message = "Unable to access NFC. Please try again.", + ), + ) + return + } + + val nfc = manager.getOrCreateNfc(tapSigner) + val nfcManager = TapCardNfcManager.getInstance() + nfcManager.onMessageUpdate = { message -> manager.scanMessage = message } + nfcManager.onTagDetected = { manager.isTagDetected = true } + + manager.scanMessage = "Hold your phone near the TapSigner to continue setup" + manager.isTagDetected = false + manager.isScanning = true + + try { + val result = nfc.continueSetup(response) + manager.isScanning = false + manager.isTagDetected = false + nfcManager.onMessageUpdate = null + nfcManager.onTagDetected = null + + when (result) { + is SetupCmdResponse.Complete -> { + manager.resetRoute(TapSignerRoute.SetupSuccess(tapSigner, result.v1)) + } + else -> { + manager.resetRoute(TapSignerRoute.SetupRetry(tapSigner, result)) + } } + } catch (e: Exception) { + manager.isScanning = false + manager.isTagDetected = false + nfcManager.onMessageUpdate = null + nfcManager.onTagDetected = null + + app.sheetState = null + app.alertState = + TaggedItem( + AppAlertState.TapSignerSetupFailed( + e.message ?: "Unknown error", + ), + ) } + + setRunning(false) } diff --git a/ios/Cove/Flows/TapSignerFlow/TapSignerSetupRetryView.swift b/ios/Cove/Flows/TapSignerFlow/TapSignerSetupRetryView.swift index 27c9611e1..9165c524a 100644 --- a/ios/Cove/Flows/TapSignerFlow/TapSignerSetupRetryView.swift +++ b/ios/Cove/Flows/TapSignerFlow/TapSignerSetupRetryView.swift @@ -15,21 +15,28 @@ struct TapSignerSetupRetry: View { let tapSigner: TapSigner let response: SetupCmdResponse + @State private var isRunning = false + @State private var didSaveBackup = false + + private var availableBackup: Data? { + switch response { + case let .continueFromBackup(c): c.backup + case let .continueFromDerive(c): c.backup + case .continueFromInit, .complete: nil + } + } + var body: some View { - VStack(spacing: 40) { - VStack { - HStack { - Button(action: { manager.popRoute() }) { - Text("Cancel") - } + if let backup = availableBackup { + saveBackupBody(backup: backup) + } else { + errorBody + } + } - Spacer() - } - .padding(.top, 20) - .padding(.horizontal, 10) - .foregroundStyle(.primary) - .fontWeight(.semibold) - } + var errorBody: some View { + VStack(spacing: 40) { + header Spacer() @@ -56,48 +63,156 @@ struct TapSignerSetupRetry: View { Spacer() VStack(spacing: 14) { - Button("Retry") { - Task { - let nfc = manager.getOrCreateNfc(tapSigner) - switch await nfc.continueSetup(response) { - case let .success(.complete(c)): - manager.resetRoute(to: .setupSuccess(tapSigner, c)) - case let .success(incomplete): - Log.error( - "Failed to complete TAPSIGNER setup, won't retry anymore \(incomplete)" - ) - app.sheetState = nil - app.alertState = .init( - .tapSignerSetupFailed(message: "Failed to setup TapSigner") - ) - case let .failure(error): - app.sheetState = nil - app.alertState = .init(.tapSignerSetupFailed(message: error.description)) + Button("Retry") { runContinueSetup() } + .disabled(isRunning) + .buttonStyle(DarkButtonStyle()) + .padding(.horizontal) + } + } + .background(patternBackground) + .scrollIndicators(.hidden) + .navigationBarHidden(true) + } + + func saveBackupBody(backup: Data) -> some View { + VStack(spacing: 32) { + header + + Spacer() + + VStack(spacing: 20) { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 100)) + .foregroundStyle(.orange) + .fontWeight(.light) + + Text("Almost there") + .font(.title) + .fontWeight(.bold) + + Text( + "Your TAPSIGNER backup was created successfully, but setup didn't fully complete. Please download your backup now, then continue to finish setup." + ) + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.8)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal) + + ShareLink( + item: BackupExport( + content: hexEncode(bytes: backup), + filename: "\(tapSigner.identFileNamePrefix())_backup.txt" + ), + preview: SharePreview("\(tapSigner.identFileNamePrefix())_backup.txt") + ) { + HStack { + VStack(spacing: 4) { + HStack { + Text("Download Backup") + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color.primary) + Spacer() + } + + HStack { + Text("You need this backup to restore your wallet.") + .foregroundStyle(Color.secondary) + Spacer() } } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Color.secondary) } - .buttonStyle(DarkButtonStyle()) - .padding(.horizontal) + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } - } - .background( - VStack { - Image(.chainCodePattern) - .resizable() - .aspectRatio(contentMode: .fit) - .ignoresSafeArea(edges: .all) - .padding(.top, 5) + .font(.footnote) + .fontWeight(.semibold) + .padding(.horizontal) - Spacer() + Spacer() + + VStack(spacing: 14) { + Button("Continue") { runContinueSetup() } + .disabled(isRunning) + .buttonStyle(DarkButtonStyle()) + .padding(.horizontal) } - .opacity(0.8) - ) + } + .task { + guard !didSaveBackup else { return } + didSaveBackup = true + let _ = app.saveTapSignerBackup(tapSigner, backup) + } + .background(patternBackground) .scrollIndicators(.hidden) .navigationBarHidden(true) } + + var header: some View { + VStack { + HStack { + Button(action: { manager.popRoute() }) { + Text("Cancel") + } + + Spacer() + } + .padding(.top, 20) + .padding(.horizontal, 10) + .foregroundStyle(.primary) + .fontWeight(.semibold) + } + } + + var patternBackground: some View { + VStack { + Image(.chainCodePattern) + .resizable() + .aspectRatio(contentMode: .fit) + .ignoresSafeArea(edges: .all) + .padding(.top, 5) + + Spacer() + } + .opacity(0.8) + } + + func runContinueSetup() { + guard !isRunning else { return } + isRunning = true + + Task { + let nfc = manager.getOrCreateNfc(tapSigner) + switch await nfc.continueSetup(response) { + case let .success(.complete(c)): + manager.resetRoute(to: .setupSuccess(tapSigner, c)) + case let .success(incomplete): + Log.error( + "Failed to complete TAPSIGNER setup, won't retry anymore \(incomplete)" + ) + app.sheetState = nil + app.alertState = .init( + .tapSignerSetupFailed(message: "Failed to set up TAPSIGNER") + ) + case let .failure(error): + app.sheetState = nil + app.alertState = .init(.tapSignerSetupFailed(message: error.description)) + } + + isRunning = false + } + } } -#Preview { +#Preview("Continue from backup") { TapSignerContainer( route: .setupRetry(