From 3cd9f6234a6200075772993ffcc289f430373f1a Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Mon, 27 Jul 2026 10:29:00 +0000 Subject: [PATCH 1/4] Fix checkIn refetching all vaults instead of the checked-in one VaultViewModel.checkIn called load() on success, refetching and replacing every vault in the list for a single check-in (mirrors iOS #16). Add refreshSingle(vaultId), backed by the previously-unused ApiClient.getVault(id), and patch just that vault into the existing list. --- .../main/java/com/ethosprotocol/ui/ViewModels.kt | 15 ++++++++++++++- .../java/com/ethosprotocol/VaultViewModelTest.kt | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index fd51d55..c2c2c38 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -159,7 +159,7 @@ class VaultViewModel @Inject constructor( fun checkIn(vaultId: String) = viewModelScope.launch { when (val result = apiClient.checkIn(vaultId)) { - is ApiResult.Success -> load() + is ApiResult.Success -> refreshSingle(vaultId) is ApiResult.Error -> _state.update { it.copy(error = result.message) } ApiResult.NetworkUnavailable -> { pendingCheckInDao.insert(PendingCheckIn(vaultId = vaultId, queuedAt = System.currentTimeMillis())) @@ -171,6 +171,15 @@ class VaultViewModel @Inject constructor( } } + /** Refetches a single vault and patches it into the existing list, instead of reloading all vaults. */ + fun refreshSingle(vaultId: String) = viewModelScope.launch { + when (val result = apiClient.getVault(vaultId)) { + is ApiResult.Success -> updateVaultInPlace(result.data) + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } + fun createVault(beneficiary: String, intervalDays: Int) = viewModelScope.launch { val req = CreateVaultRequest(beneficiary, intervalDays * 86_400L) when (val result = apiClient.createVault(req)) { @@ -179,6 +188,10 @@ class VaultViewModel @Inject constructor( ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } } } + + private fun updateVaultInPlace(vault: Vault) { + _state.update { state -> state.copy(vaults = state.vaults.map { if (it.id == vault.id) vault else it }) } + } } // --- Acceptance ViewModel --- diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index d3e8420..6628f87 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -76,15 +76,23 @@ class VaultViewModelTest { } @Test - fun `checkIn success reloads vaults`() = runTest { - val vaults = listOf(makeVault("v1")) + fun `checkIn success refreshes only the checked-in vault`() = runTest { + val v1 = makeVault("v1") + val v2 = makeVault("v2") + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v1, v2)) + vm.load() + + val refreshedV1 = v1.copy(lastCheckIn = "2026-05-01T00:00:00Z", ttlRemaining = 2_592_000L) coEvery { apiClient.checkIn("v1") } returns ApiResult.Success(Unit) - coEvery { apiClient.listVaults() } returns ApiResult.Success(vaults) + coEvery { apiClient.getVault("v1") } returns ApiResult.Success(refreshedV1) vm.checkIn("v1") coVerify { apiClient.checkIn("v1") } - coVerify { apiClient.listVaults() } + coVerify { apiClient.getVault("v1") } + coVerify(exactly = 1) { apiClient.listVaults() } + coVerify(exactly = 0) { apiClient.getVault("v2") } + assertEquals(listOf(refreshedV1, v2), vm.state.value.vaults) } @Test From b55b14e5cf217fb9a2d9368d37da93e21a8cbf6a Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Mon, 27 Jul 2026 10:30:30 +0000 Subject: [PATCH 2/4] Implement beneficiary management for VaultDeepLinkScreen VaultDeepLinkScreen's MANAGE_BENEFICIARY case hardcoded an "unavailable" message and no API method existed (mirrors iOS #15). Add ApiClient.updateBeneficiary using the /vaults/{id}/beneficiary endpoint already documented in shared/api-contract.md, VaultViewModel.updateBeneficiary, and a ManageBeneficiaryDialog gated behind BiometricHelper confirmation given the sensitivity of changing a vault's beneficiary. --- .../java/com/ethosprotocol/api/ApiClient.kt | 2 + .../java/com/ethosprotocol/models/Models.kt | 3 + .../java/com/ethosprotocol/ui/ViewModels.kt | 8 ++ .../com/ethosprotocol/ui/screens/Screens.kt | 75 ++++++++++++++++++- .../com/ethosprotocol/VaultViewModelTest.kt | 34 +++++++++ 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index ac6be0a..6f3e081 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -68,6 +68,8 @@ class ApiClient @Inject constructor( // Beneficiary suspend fun acceptBeneficiary(vaultId: String): ApiResult = post("/vaults/$vaultId/accept", Unit) + suspend fun updateBeneficiary(vaultId: String, newBeneficiary: String): ApiResult = + post("/vaults/$vaultId/beneficiary", BeneficiaryUpdateRequest(newBeneficiary)) // 2FA suspend fun get2FAStatus(vaultId: String): ApiResult = get("/vaults/$vaultId/2fa/status") diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index ecb2df2..b778509 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -39,6 +39,9 @@ data class CreateVaultRequest( @SerialName("check_in_interval") val checkInInterval: Long ) +@Serializable +data class BeneficiaryUpdateRequest(val beneficiary: String) + @Serializable data class PushRegistration( val token: String, diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index c2c2c38..f5e26b4 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -189,6 +189,14 @@ class VaultViewModel @Inject constructor( } } + fun updateBeneficiary(vaultId: String, newBeneficiary: String) = viewModelScope.launch { + when (val result = apiClient.updateBeneficiary(vaultId, newBeneficiary)) { + is ApiResult.Success -> updateVaultInPlace(result.data) + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } + private fun updateVaultInPlace(vault: Vault) { _state.update { state -> state.copy(vaults = state.vaults.map { if (it.id == vault.id) vault else it }) } } diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 8ed6857..7004969 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -336,6 +336,7 @@ fun VaultDeepLinkScreen( val context = LocalContext.current var isProcessing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + var showBeneficiaryDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { vm.load() } @@ -416,15 +417,83 @@ fun VaultDeepLinkScreen( } VaultDeepLinkAction.MANAGE_BENEFICIARY -> { Button( - onClick = { error = "Beneficiary management is not yet available in the mobile app." }, - modifier = Modifier.fillMaxWidth() - ) { Text("Manage Beneficiary") } + onClick = { + if (vault == null) { + error = "Vault not found" + return@Button + } + error = null + showBeneficiaryDialog = true + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isProcessing && vault != null + ) { Text(if (isProcessing) "Processing…" else "Manage Beneficiary") } } VaultDeepLinkAction.VIEW_DETAILS, null -> Unit } Spacer(Modifier.height(8.dp)) OutlinedButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") } } + + if (showBeneficiaryDialog && vault != null) { + ManageBeneficiaryDialog( + currentBeneficiary = vault.beneficiary, + onSubmit = { newBeneficiary -> + showBeneficiaryDialog = false + isProcessing = true + error = null + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Confirm Beneficiary Change", + subtitle = "Vault ${vault.id.take(12)}… beneficiary will change to ${newBeneficiary.take(12)}…", + onSuccess = { + vm.updateBeneficiary(vault.id, newBeneficiary) + isProcessing = false + onDone() + }, + onError = { err -> + error = err + isProcessing = false + } + ) + }, + onDismiss = { showBeneficiaryDialog = false } + ) + } +} + +@Composable +private fun ManageBeneficiaryDialog( + currentBeneficiary: String, + onSubmit: (String) -> Unit, + onDismiss: () -> Unit +) { + var beneficiary by remember { mutableStateOf(currentBeneficiary) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Manage Beneficiary") }, + text = { + Column { + Text( + "This vault's funds are released to this address if it is never checked into again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = beneficiary, onValueChange = { beneficiary = it }, + label = { Text("Beneficiary address") }, singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSubmit(beneficiary) }, + enabled = beneficiary.isNotBlank() && beneficiary != currentBeneficiary + ) { Text("Continue") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) } @Composable diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index 6628f87..7e1155a 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -104,6 +104,40 @@ class VaultViewModelTest { assertNotNull(vm.state.value.error) } + @Test + fun `updateBeneficiary success updates only the target vault`() = runTest { + val v1 = makeVault("v1") + val v2 = makeVault("v2") + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v1, v2)) + vm.load() + + val updatedV1 = v1.copy(beneficiary = "GNEWBENEFICIARY") + coEvery { apiClient.updateBeneficiary("v1", "GNEWBENEFICIARY") } returns ApiResult.Success(updatedV1) + + vm.updateBeneficiary("v1", "GNEWBENEFICIARY") + + coVerify { apiClient.updateBeneficiary("v1", "GNEWBENEFICIARY") } + assertEquals(listOf(updatedV1, v2), vm.state.value.vaults) + } + + @Test + fun `updateBeneficiary error sets error message`() = runTest { + coEvery { apiClient.updateBeneficiary("v1", "GNEW") } returns ApiResult.Error("Server error", 500) + + vm.updateBeneficiary("v1", "GNEW") + + assertEquals("Server error", vm.state.value.error) + } + + @Test + fun `updateBeneficiary network unavailable sets error`() = runTest { + coEvery { apiClient.updateBeneficiary("v1", "GNEW") } returns ApiResult.NetworkUnavailable + + vm.updateBeneficiary("v1", "GNEW") + + assertNotNull(vm.state.value.error) + } + private fun makeVault(id: String) = Vault( id = id, owner = "GABC", beneficiary = "GXYZ", balance = 10_000_000L, checkInInterval = 2_592_000L, From 7b7f5de5475d2ac76ee344b272f25ee923a2feb5 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Mon, 27 Jul 2026 10:33:41 +0000 Subject: [PATCH 3/4] Implement withdraw flow for vault detail UI and deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiClient.withdraw was fully implemented but unreachable from the UI — both the vault card and VaultDeepLinkScreen's WITHDRAW case hardcoded an "unavailable" message (mirrors iOS #14). Add VaultViewModel.withdraw, a WithdrawDialog with balance-sufficiency validation, and wire it into both the VaultCard "Withdraw" action and VaultDeepLinkScreen, gated behind BiometricHelper confirmation. --- .../java/com/ethosprotocol/ui/ViewModels.kt | 8 ++ .../com/ethosprotocol/ui/screens/Screens.kt | 105 +++++++++++++++++- .../com/ethosprotocol/VaultViewModelTest.kt | 34 ++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index f5e26b4..4717714 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -189,6 +189,14 @@ class VaultViewModel @Inject constructor( } } + fun withdraw(vaultId: String, amount: Long) = viewModelScope.launch { + when (val result = apiClient.withdraw(vaultId, amount)) { + is ApiResult.Success -> updateVaultInPlace(result.data) + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } + fun updateBeneficiary(vaultId: String, newBeneficiary: String) = viewModelScope.launch { when (val result = apiClient.updateBeneficiary(vaultId, newBeneficiary)) { is ApiResult.Success -> updateVaultInPlace(result.data) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 7004969..67f058a 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -100,6 +100,7 @@ fun VaultListScreen( val context = LocalContext.current var showCreate by remember { mutableStateOf(false) } var pendingCheckIn by remember { mutableStateOf(null) } + var withdrawVault by remember { mutableStateOf(null) } var biometricError by remember { mutableStateOf(null) } LaunchedEffect(Unit) { vm.load() } @@ -127,6 +128,22 @@ fun VaultListScreen( ) } + withdrawVault?.let { vault -> + WithdrawDialog( + vault = vault, + onSubmit = { amount -> + withdrawVault = null + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Confirm Withdrawal", + subtitle = "Withdraw from vault ${vault.id.take(12)}…", + onSuccess = { vm.withdraw(vault.id, amount) }, + onError = { err -> biometricError = err }, + ) + }, + onDismiss = { withdrawVault = null }, + ) + } + Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { @@ -159,6 +176,7 @@ fun VaultListScreen( vault = vault, onClick = { onVaultClick(vault.id) }, onCheckIn = { pendingCheckIn = vault }, + onWithdraw = { withdrawVault = vault }, ) } } @@ -212,7 +230,12 @@ private fun OfflineBanner() { } @Composable -private fun VaultCard(vault: Vault, onClick: () -> Unit, onCheckIn: () -> Unit) { +private fun VaultCard( + vault: Vault, + onClick: () -> Unit, + onCheckIn: () -> Unit, + onWithdraw: () -> Unit = {} +) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Column(Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -238,11 +261,51 @@ private fun VaultCard(vault: Vault, onClick: () -> Unit, onCheckIn: () -> Unit) OutlinedButton(onClick = onCheckIn, modifier = Modifier.fillMaxWidth()) { Text("Check In") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onWithdraw, modifier = Modifier.fillMaxWidth()) { + Text("Withdraw") + } } } } } +@Composable +private fun WithdrawDialog(vault: Vault, onSubmit: (Long) -> Unit, onDismiss: () -> Unit) { + var amountText by remember { mutableStateOf("") } + val amountStroops = amountText.toDoubleOrNull()?.takeIf { it > 0 }?.let { (it * 10_000_000).toLong() } + val isSufficient = amountStroops != null && amountStroops <= vault.balance + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Withdraw") }, + text = { + Column { + Text("Available balance: ${vault.formattedBalance}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = amountText, onValueChange = { amountText = it }, + label = { Text("Amount (XLM)") }, singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (amountStroops != null && !isSufficient) { + Spacer(Modifier.height(4.dp)) + Text("Amount exceeds available balance.", color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSubmit(amountStroops!!) }, + enabled = amountStroops != null && isSufficient + ) { Text("Withdraw") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + @Composable private fun StatusChip(status: com.ethosprotocol.models.VaultStatus) { val (label, color) = when (status) { @@ -337,6 +400,7 @@ fun VaultDeepLinkScreen( var isProcessing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var showBeneficiaryDialog by remember { mutableStateOf(false) } + var showWithdrawDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { vm.load() } @@ -411,9 +475,17 @@ fun VaultDeepLinkScreen( } VaultDeepLinkAction.WITHDRAW -> { Button( - onClick = { error = "Withdrawal is not yet available in the mobile app." }, - modifier = Modifier.fillMaxWidth() - ) { Text("Withdraw") } + onClick = { + if (vault == null) { + error = "Vault not found" + return@Button + } + error = null + showWithdrawDialog = true + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isProcessing && vault != null + ) { Text(if (isProcessing) "Processing…" else "Withdraw") } } VaultDeepLinkAction.MANAGE_BENEFICIARY -> { Button( @@ -459,6 +531,31 @@ fun VaultDeepLinkScreen( onDismiss = { showBeneficiaryDialog = false } ) } + + if (showWithdrawDialog && vault != null) { + WithdrawDialog( + vault = vault, + onSubmit = { amount -> + showWithdrawDialog = false + isProcessing = true + error = null + BiometricHelper(context as androidx.fragment.app.FragmentActivity).authenticate( + title = "Confirm Withdrawal", + subtitle = "Withdraw from vault ${vault.id.take(12)}…", + onSuccess = { + vm.withdraw(vault.id, amount) + isProcessing = false + onDone() + }, + onError = { err -> + error = err + isProcessing = false + } + ) + }, + onDismiss = { showWithdrawDialog = false } + ) + } } @Composable diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index 7e1155a..d7995b3 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -104,6 +104,40 @@ class VaultViewModelTest { assertNotNull(vm.state.value.error) } + @Test + fun `withdraw success updates only the target vault`() = runTest { + val v1 = makeVault("v1") + val v2 = makeVault("v2") + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v1, v2)) + vm.load() + + val updatedV1 = v1.copy(balance = v1.balance - 1_000_000L) + coEvery { apiClient.withdraw("v1", 1_000_000L) } returns ApiResult.Success(updatedV1) + + vm.withdraw("v1", 1_000_000L) + + coVerify { apiClient.withdraw("v1", 1_000_000L) } + assertEquals(listOf(updatedV1, v2), vm.state.value.vaults) + } + + @Test + fun `withdraw error sets error message`() = runTest { + coEvery { apiClient.withdraw("v1", 1_000_000L) } returns ApiResult.Error("Insufficient balance", 400) + + vm.withdraw("v1", 1_000_000L) + + assertEquals("Insufficient balance", vm.state.value.error) + } + + @Test + fun `withdraw network unavailable sets error`() = runTest { + coEvery { apiClient.withdraw("v1", 1_000_000L) } returns ApiResult.NetworkUnavailable + + vm.withdraw("v1", 1_000_000L) + + assertNotNull(vm.state.value.error) + } + @Test fun `updateBeneficiary success updates only the target vault`() = runTest { val v1 = makeVault("v1") From 66db6731b21679a4f8a28c551e0b69b21c471b32 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Mon, 27 Jul 2026 10:35:29 +0000 Subject: [PATCH 4/4] Implement deposit flow for vault detail UI ApiClient.deposit was implemented but unused anywhere in ui/ (mirrors iOS #13). Add VaultViewModel.deposit, a DepositDialog parallel to CreateVaultDialog, and a "Deposit" entry point on the vault card next to the new "Withdraw" action. --- .../java/com/ethosprotocol/ui/ViewModels.kt | 8 ++++ .../com/ethosprotocol/ui/screens/Screens.kt | 43 ++++++++++++++++++- .../com/ethosprotocol/VaultViewModelTest.kt | 34 +++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 4717714..cd259c8 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -189,6 +189,14 @@ class VaultViewModel @Inject constructor( } } + fun deposit(vaultId: String, amount: Long) = viewModelScope.launch { + when (val result = apiClient.deposit(vaultId, amount)) { + is ApiResult.Success -> updateVaultInPlace(result.data) + is ApiResult.Error -> _state.update { it.copy(error = result.message) } + ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } + } + } + fun withdraw(vaultId: String, amount: Long) = viewModelScope.launch { when (val result = apiClient.withdraw(vaultId, amount)) { is ApiResult.Success -> updateVaultInPlace(result.data) diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 67f058a..2f4af1d 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -101,6 +101,7 @@ fun VaultListScreen( var showCreate by remember { mutableStateOf(false) } var pendingCheckIn by remember { mutableStateOf(null) } var withdrawVault by remember { mutableStateOf(null) } + var depositVault by remember { mutableStateOf(null) } var biometricError by remember { mutableStateOf(null) } LaunchedEffect(Unit) { vm.load() } @@ -144,6 +145,16 @@ fun VaultListScreen( ) } + depositVault?.let { vault -> + DepositDialog( + onSubmit = { amount -> + depositVault = null + vm.deposit(vault.id, amount) + }, + onDismiss = { depositVault = null }, + ) + } + Scaffold( topBar = { TopAppBar(title = { Text("My Vaults") }, actions = { @@ -176,6 +187,7 @@ fun VaultListScreen( vault = vault, onClick = { onVaultClick(vault.id) }, onCheckIn = { pendingCheckIn = vault }, + onDeposit = { depositVault = vault }, onWithdraw = { withdrawVault = vault }, ) } @@ -234,6 +246,7 @@ private fun VaultCard( vault: Vault, onClick: () -> Unit, onCheckIn: () -> Unit, + onDeposit: () -> Unit = {}, onWithdraw: () -> Unit = {} ) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { @@ -262,8 +275,13 @@ private fun VaultCard( Text("Check In") } Spacer(Modifier.height(8.dp)) - OutlinedButton(onClick = onWithdraw, modifier = Modifier.fillMaxWidth()) { - Text("Withdraw") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDeposit, modifier = Modifier.weight(1f)) { + Text("Deposit") + } + OutlinedButton(onClick = onWithdraw, modifier = Modifier.weight(1f)) { + Text("Withdraw") + } } } } @@ -619,6 +637,27 @@ private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> ) } +@Composable +private fun DepositDialog(onSubmit: (Long) -> Unit, onDismiss: () -> Unit) { + var amountText by remember { mutableStateOf("") } + val amountStroops = amountText.toDoubleOrNull()?.takeIf { it > 0 }?.let { (it * 10_000_000).toLong() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Deposit") }, + text = { + OutlinedTextField( + value = amountText, onValueChange = { amountText = it }, + label = { Text("Amount (XLM)") }, singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onSubmit(amountStroops!!) }, enabled = amountStroops != null) { Text("Deposit") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + // MARK: - 2FA Screens @Composable diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index d7995b3..2fa2ba8 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -104,6 +104,40 @@ class VaultViewModelTest { assertNotNull(vm.state.value.error) } + @Test + fun `deposit success updates only the target vault`() = runTest { + val v1 = makeVault("v1") + val v2 = makeVault("v2") + coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v1, v2)) + vm.load() + + val updatedV1 = v1.copy(balance = v1.balance + 1_000_000L) + coEvery { apiClient.deposit("v1", 1_000_000L) } returns ApiResult.Success(updatedV1) + + vm.deposit("v1", 1_000_000L) + + coVerify { apiClient.deposit("v1", 1_000_000L) } + assertEquals(listOf(updatedV1, v2), vm.state.value.vaults) + } + + @Test + fun `deposit error sets error message`() = runTest { + coEvery { apiClient.deposit("v1", 1_000_000L) } returns ApiResult.Error("Server error", 500) + + vm.deposit("v1", 1_000_000L) + + assertEquals("Server error", vm.state.value.error) + } + + @Test + fun `deposit network unavailable sets error`() = runTest { + coEvery { apiClient.deposit("v1", 1_000_000L) } returns ApiResult.NetworkUnavailable + + vm.deposit("v1", 1_000_000L) + + assertNotNull(vm.state.value.error) + } + @Test fun `withdraw success updates only the target vault`() = runTest { val v1 = makeVault("v1")