taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit b82ac78b30dcaa2957716320de18e87a5482b883
parent 03fedb848b1e0c7ab3db9819b40e849ce4fa7cd2
Author: Jonathan Buchanan <jonathan.russ.buchanan@gmail.com>
Date:   Fri, 19 Aug 2022 17:48:17 -0400

organize navigation flow for withdraw

Diffstat:
MTaler.xcodeproj/project.pbxproj | 12++++++++++++
ATaler/Model/BalancesModel.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
MTaler/Model/WithdrawModel.swift | 182++++++++++++++++++++++++++++++++++++++++++++++----------------------------------
ATaler/Views/BalancesView.swift | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTaler/Views/ContentView.swift | 16+++++++---------
MTaler/Views/SettingsView.swift | 100-------------------------------------------------------------------------------
ATaler/Views/WithdrawView.swift | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 379 insertions(+), 186 deletions(-)

diff --git a/Taler.xcodeproj/project.pbxproj b/Taler.xcodeproj/project.pbxproj @@ -9,7 +9,10 @@ /* Begin PBXBuildFile section */ AB1F87C82887C94700AB82A0 /* TalerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1F87C72887C94700AB82A0 /* TalerApp.swift */; }; AB1F87CA2887D2F400AB82A0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1F87C92887D2F400AB82A0 /* ContentView.swift */; }; + AB4C534A28AC21C9003004F7 /* BalancesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4C534928AC21C9003004F7 /* BalancesView.swift */; }; + AB4C534C28AC25FC003004F7 /* BalancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4C534B28AC25FC003004F7 /* BalancesModel.swift */; }; AB69F9FA28AAED53005CCC2E /* WithdrawModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */; }; + AB7356F928B0203B009C5D8C /* WithdrawView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7356F828B0203B009C5D8C /* WithdrawView.swift */; }; AB8C3807286A88A600E0A1DD /* WalletBackendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8C3806286A88A500E0A1DD /* WalletBackendTests.swift */; }; ABB33065289C5BBB00668B42 /* ExchangeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB33064289C5BBB00668B42 /* ExchangeManager.swift */; }; ABB33067289C658900668B42 /* BackendManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB33066289C658900668B42 /* BackendManager.swift */; }; @@ -57,8 +60,11 @@ /* Begin PBXFileReference section */ AB1F87C72887C94700AB82A0 /* TalerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalerApp.swift; sourceTree = "<group>"; }; AB1F87C92887D2F400AB82A0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; + AB4C534928AC21C9003004F7 /* BalancesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalancesView.swift; sourceTree = "<group>"; }; + AB4C534B28AC25FC003004F7 /* BalancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalancesModel.swift; sourceTree = "<group>"; }; AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawModel.swift; sourceTree = "<group>"; }; AB710490285995B6008B04F0 /* taler-swift */ = {isa = PBXFileReference; lastKnownFileType = text; path = "taler-swift"; sourceTree = SOURCE_ROOT; }; + AB7356F828B0203B009C5D8C /* WithdrawView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawView.swift; sourceTree = "<group>"; }; AB8C3806286A88A500E0A1DD /* WalletBackendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletBackendTests.swift; sourceTree = "<group>"; }; ABB33064289C5BBB00668B42 /* ExchangeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangeManager.swift; sourceTree = "<group>"; }; ABB33066289C658900668B42 /* BackendManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendManager.swift; sourceTree = "<group>"; }; @@ -113,6 +119,8 @@ AB1F87C92887D2F400AB82A0 /* ContentView.swift */, ABB762AC2891059600E88634 /* SettingsView.swift */, ABC4AC3A28A4619C0047A56F /* PendingView.swift */, + AB4C534928AC21C9003004F7 /* BalancesView.swift */, + AB7356F828B0203B009C5D8C /* WithdrawView.swift */, ); path = Views; sourceTree = "<group>"; @@ -124,6 +132,7 @@ ABB33064289C5BBB00668B42 /* ExchangeManager.swift */, ABC4AC3E28A473070047A56F /* PendingManager.swift */, AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */, + AB4C534B28AC25FC003004F7 /* BalancesModel.swift */, ); path = Model; sourceTree = "<group>"; @@ -374,9 +383,12 @@ files = ( AB1F87C82887C94700AB82A0 /* TalerApp.swift in Sources */, AB1F87CA2887D2F400AB82A0 /* ContentView.swift in Sources */, + AB7356F928B0203B009C5D8C /* WithdrawView.swift in Sources */, ABB33067289C658900668B42 /* BackendManager.swift in Sources */, AB69F9FA28AAED53005CCC2E /* WithdrawModel.swift in Sources */, + AB4C534C28AC25FC003004F7 /* BalancesModel.swift in Sources */, ABB33065289C5BBB00668B42 /* ExchangeManager.swift in Sources */, + AB4C534A28AC21C9003004F7 /* BalancesView.swift in Sources */, D1D65B9826992E4600C1012A /* WalletBackend.swift in Sources */, ABB762AD2891059600E88634 /* SettingsView.swift in Sources */, ABC4AC3B28A4619C0047A56F /* PendingView.swift in Sources */, diff --git a/Taler/Model/BalancesModel.swift b/Taler/Model/BalancesModel.swift @@ -0,0 +1,49 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import Foundation + +class BalancesModel: ObservableObject { + enum State { + case begin + case loading + case loaded([Balance]) + } + + var backend: WalletBackend + @Published var state: State + + init(backend: WalletBackend) { + self.backend = backend + self.state = .begin + } + + func getBalances() { + self.state = .loading + let req = WalletBackendGetBalancesRequest() + backend.sendFormattedRequest(request: req) { response, err in + // TODO: Use Combine instead + DispatchQueue.main.async { + if let res = response { + self.state = .loaded(res.balances) + } else { + // TODO: Handle error. + self.state = .begin + } + } + } + } +} diff --git a/Taler/Model/WithdrawModel.swift b/Taler/Model/WithdrawModel.swift @@ -29,33 +29,107 @@ func paytoUriGetSubject(uri: String) -> String { })!.value!.removingPercentEncoding!.replacingOccurrences(of: "+", with: " ") } -class WithdrawModel: ObservableObject { - enum State { - case begin - case loading - case prompt(rawAmount: Amount, - effectiveAmount: Amount) - case promptTOS(rawAmount: Amount, - effectiveAmount: Amount, - tos: String, - etag: String) - case manualTransfer(rawAmount: Amount, - effectiveAmount: Amount, - paytoUri: String) +typealias WithdrawDetails = WalletBackendGetWithdrawalDetailsForAmountRequest.Response +typealias TOSDetails = WalletBackendGetExchangeTermsOfService.Response + +class ManualTransferModel: ObservableObject { + let backend: WalletBackend + let exchange: ExchangeItem + var details: WithdrawDetails! + var paytoUri: String! + @Published var loading: Bool = false + @Published var nav: Bool = false + + init(backend: WalletBackend, exchange: ExchangeItem) { + self.backend = backend + self.exchange = exchange + } + + func loadDetails(_ newDetails: WithdrawDetails, _ newPaytoUri: String) { + self.details = newDetails + self.paytoUri = newPaytoUri + } +} + +class PromptWithdrawModel: ObservableObject { + let backend: WalletBackend + let exchange: ExchangeItem + var details: WithdrawDetails! + var tosDetails: TOSDetails? + @Published var tosAccepted: Bool! + @Published var loading: Bool = false + @Published var navTos: Bool = false + @Published var nav: Bool = false + + var manualTransferModel: ManualTransferModel + + init(backend: WalletBackend, exchange: ExchangeItem) { + self.backend = backend + self.exchange = exchange + self.manualTransferModel = ManualTransferModel(backend: backend, exchange: exchange) } - var backend: WalletBackend + func loadDetails(_ newDetails: WithdrawDetails) { + self.details = newDetails + self.tosAccepted = details.tosAccepted + } + + func acceptTos() { + self.loading = true + let req = WalletBackendSetExchangeTermsOfServiceAccepted(exchangeBaseUrl: exchange.exchangeBaseUrl, + etag: tosDetails!.currentEtag) + backend.sendFormattedRequest(request: req) { response, err in + // TODO: Use Combine instead + DispatchQueue.main.async { + self.loading = false + if let _ = response { + self.tosAccepted = true + self.navTos = false + } else { + // TODO: Handle error. + } + } + } + } + + func acceptWithdraw() { + // TODO: Include an option for a withdraw payto uri. + self.loading = true + let req = WalletBackendAcceptManualWithdrawalRequest(exchangeBaseUrl: exchange.exchangeBaseUrl, + amount: details.amountRaw) + backend.sendFormattedRequest(request: req) { response, err in + // TODO: Use Combine instead + DispatchQueue.main.async { + self.loading = false + if let res = response { + self.manualTransferModel.loadDetails(self.details, res.exchangePaytoUris[0]) + self.nav = true + } else { + // TODO: Show error. + self.loading = false + } + } + } + } +} + +class WithdrawModel: ObservableObject { + let backend: WalletBackend let exchange: ExchangeItem - @Published var state: State + var details: WithdrawDetails? + @Published var loading: Bool = false + @Published var nav: Bool = false + + var promptModel: PromptWithdrawModel init(backend: WalletBackend, exchange: ExchangeItem) { self.backend = backend self.exchange = exchange - self.state = .begin + self.promptModel = PromptWithdrawModel(backend: backend, exchange: exchange) } func getWithdrawDetails(amountStr: String) { - self.state = .loading + self.loading = true do { let amount = try Amount(fromString: amountStr) let req = WalletBackendGetWithdrawalDetailsForAmountRequest(exchangeBaseUrl: exchange.exchangeBaseUrl, @@ -64,88 +138,42 @@ class WithdrawModel: ObservableObject { // TODO: Use Combine instead. DispatchQueue.main.async { if let res = response { + self.details = res + self.promptModel.loadDetails(res) if res.tosAccepted { - self.state = .prompt(rawAmount: res.amountRaw, effectiveAmount: res.amountEffective) + self.loading = false + self.nav = true } else { - self.getTos(rawAmount: res.amountRaw, effectiveAmount: res.amountEffective) + self.getTos() } } else { - self.state = .begin // TODO: Show error. + self.loading = false } } } } catch { - self.state = .begin // TODO: Show error. + self.loading = false } } - private func getTos(rawAmount: Amount, effectiveAmount: Amount) { - self.state = .loading + private func getTos() { + self.loading = true let req = WalletBackendGetExchangeTermsOfService(exchangeBaseUrl: exchange.exchangeBaseUrl) backend.sendFormattedRequest(request: req) { response, err in // TODO: Use Combine instead DispatchQueue.main.async { + self.loading = false if let res = response { - print(res) - self.state = .promptTOS(rawAmount: rawAmount, - effectiveAmount: effectiveAmount, - tos: res.content, - etag: res.currentEtag) + self.promptModel.tosDetails = res + self.loading = false + self.nav = true } else { - self.state = .begin // TODO: Show error. + self.loading = false } } } } - - func acceptTos() { - let oldState = self.state - self.state = .loading - switch oldState { - case .promptTOS(_, _, _, let etag): - let req = WalletBackendSetExchangeTermsOfServiceAccepted(exchangeBaseUrl: exchange.exchangeBaseUrl, - etag: etag) - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead - DispatchQueue.main.async { - self.state = oldState - // TODO: Handle error. - } - } - default: - self.state = oldState - // TODO: Show error. - } - } - - func acceptWithdraw() { - // TODO: Include an option for a withdraw payto uri. - let oldState = self.state - self.state = .loading - switch oldState { - case .prompt(let rawAmount, let effectiveAmount): - let req = WalletBackendAcceptManualWithdrawalRequest(exchangeBaseUrl: exchange.exchangeBaseUrl, - amount: rawAmount) - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead - DispatchQueue.main.async { - if let res = response { - // TODO: Error if there are no URIs. - self.state = .manualTransfer(rawAmount: rawAmount, - effectiveAmount: effectiveAmount, - paytoUri: res.exchangePaytoUris[0]) - } else { - // TODO: Handle error. - self.state = oldState - } - } - } - default: - self.state = oldState - // TODO: Show error. - } - } } diff --git a/Taler/Views/BalancesView.swift b/Taler/Views/BalancesView.swift @@ -0,0 +1,64 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import SwiftUI + +struct BalancesView: View { + @ObservedObject var balancesModel: BalancesModel + @EnvironmentObject var backend: BackendManager + var showSidebar: () -> Void + + var body: some View { + NavigationView { + switch balancesModel.state { + case .begin: + EmptyView() + .onAppear(perform: { + balancesModel.getBalances() + }) + .navigationTitle("Balances") + .navigationBarItems( + leading: Button(action: self.showSidebar, label: { + Image(systemName: "line.3.horizontal") + }) + ) + case .loading: + ProgressView() + .navigationTitle("Balances") + .navigationBarItems( + leading: Button(action: self.showSidebar, label: { + Image(systemName: "line.3.horizontal") + }) + ) + case .loaded(let balances): + VStack { + Text("Balances") + } + .padding(16) + .navigationTitle("Balances") + .navigationBarItems( + leading: Button(action: self.showSidebar, label: { + Image(systemName: "line.3.horizontal") + }) + ) + } + } + } + + /*init(showSidebar: @escaping () -> Void) { + self.showSidebar = showSidebar + }*/ +} diff --git a/Taler/Views/ContentView.swift b/Taler/Views/ContentView.swift @@ -26,16 +26,14 @@ struct ContentView: View { @State var sidebarVisible: Bool = false var views: [SidebarItem] {[ - SidebarItem(name: "Main", - view: AnyView(Button { [self] in - self.sidebarVisible = true - } label: { - Text("Open Sidebar") - })), + SidebarItem(name: "Balances", + view: AnyView(BalancesView(balancesModel: BalancesModel(backend: self.backend.backend)) { + self.sidebarVisible = true + }.environmentObject(backend))), SidebarItem(name: "Settings", - view: AnyView(SettingsView { - self.sidebarVisible = true - }.environmentObject(backend))), + view: AnyView(SettingsView { + self.sidebarVisible = true + }.environmentObject(backend))), SidebarItem(name: "Pending Operations", view: AnyView(PendingView(_showSidebar: { self.sidebarVisible = true diff --git a/Taler/Views/SettingsView.swift b/Taler/Views/SettingsView.swift @@ -65,106 +65,6 @@ extension View { } } -struct WithdrawView: View { - @ObservedObject var model: WithdrawModel - @State var amount: String = "" - - var body: some View { - switch model.state { - case .begin: - VStack { - Button { - - } label: { - Text("Scan Taler QR Code") - } - Text("Or transfer manually:") - HStack { - TextField(model.exchange.currency, text: $amount) - } - Button { - // TODO: Handle when the user inputs a non-valid amount - model.getWithdrawDetails(amountStr: model.exchange.currency + ":" + amount) - } label: { - Text("Check Fees") - } - } - .navigationTitle("Withdraw") - case .loading: - ProgressView() - .navigationTitle("Withdraw") - case .prompt(let rawAmount, let effectiveAmount): - VStack { - Text("Withdraw") - Text(effectiveAmount.readableDescription) - Text("Chosen Amount") - Text(rawAmount.readableDescription) - Text("Fee") - Text("- \((try! rawAmount - effectiveAmount).readableDescription)") - Text("Exchange") - Text(model.exchange.name) - Button { - model.acceptWithdraw() - } label: { - Text("Confirm Withdraw") - } - } - .navigationTitle("Withdraw") - case .promptTOS(let rawAmount, let effectiveAmount, let tos, _): - VStack { - Text("Withdraw") - Text(effectiveAmount.readableDescription) - Text("Chosen Amount") - Text(rawAmount.readableDescription) - Text("Fee") - Text("- \((try! rawAmount - effectiveAmount).readableDescription)") - Text("Exchange") - Text(model.exchange.name) - NavigationLink { - VStack { - ScrollView { - Text(tos) - } - Button { - model.acceptTos() - } label: { - Text("Accept Terms of Service") - } - - } - .navigationTitle("Review Terms of Service") - } label: { - Text("Review Terms") - } - } - .navigationTitle("Withdraw") - case .manualTransfer(let rawAmount, _, let paytoUri): - VStack { - Text("Exchange is ready for withdrawal!") - Text("To complete the process you need to wire \(rawAmount.readableDescription) to the exchange bank account.") - HStack { - Text("IBAN: ") - Text(paytoUriGetIban(uri: paytoUri)) - } - HStack { - Text("Subject: ") - Text(paytoUriGetSubject(uri: paytoUri)) - } - HStack { - Text("Chosen Amount: ") - Text(rawAmount.readableDescription) - } - HStack { - Text("Exchange: ") - Text(model.exchange.exchangeBaseUrl) - } - Text("Make sure to use the correct subject, otherwise the money will not arrive in this wallet.") - } - .navigationTitle("Withdraw") - } - } -} - struct ExchangeListView: View { @ObservedObject var exchangeManager: ExchangeManager @State var showPopup: Bool = false diff --git a/Taler/Views/WithdrawView.swift b/Taler/Views/WithdrawView.swift @@ -0,0 +1,142 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import SwiftUI +import taler_swift + +struct TransferView: View { + @ObservedObject var model: ManualTransferModel + + var body: some View { + VStack { + Text("Exchange is ready for withdrawal!") + Text("To complete the process you need to wire \(self.model.details.amountRaw.readableDescription) to the exchange bank account.") + HStack { + Text("IBAN: ") + Text(paytoUriGetIban(uri: self.model.paytoUri)) + } + HStack { + Text("Subject: ") + Text(paytoUriGetSubject(uri: self.model.paytoUri)) + } + HStack { + Text("Chosen Amount: ") + Text(self.model.details.amountRaw.readableDescription) + } + HStack { + Text("Exchange: ") + Text(self.model.exchange.exchangeBaseUrl) + } + Text("Make sure to use the correct subject, otherwise the money will not arrive in this wallet.") + } + .navigationTitle("Manual Transfer") + } +} + +struct PromptWithdrawView: View { + @ObservedObject var model: PromptWithdrawModel + + var body: some View { + VStack { + NavigationLink("", isActive: $model.nav) { + TransferView(model: model.manualTransferModel) + .onDisappear { + self.model.nav = false + } + } + if model.loading { + ProgressView() + } else { + if model.tosAccepted { + Text("Withdraw") + Text(self.model.details.amountEffective.readableDescription) + Text("Chosen Amount") + Text(self.model.details.amountRaw.readableDescription) + Text("Fee") + Text("- \((try! self.model.details.amountRaw - self.model.details.amountEffective).readableDescription)") + Text("Exchange") + Text(model.exchange.name) + Button { + self.model.acceptWithdraw() + } label: { + Text("Confirm Withdraw") + } + } else { + Text("Withdraw") + Text(self.model.details.amountEffective.readableDescription) + Text("Chosen Amount") + Text(self.model.details.amountRaw.readableDescription) + Text("Fee") + Text("- \((try! self.model.details.amountRaw - self.model.details.amountEffective).readableDescription)") + Text("Exchange") + Text(model.exchange.name) + NavigationLink(isActive: $model.navTos) { + VStack { + ScrollView { + Text(model.tosDetails!.content) + } + Button { + model.acceptTos() + } label: { + Text("Accept Terms of Service") + } + } + .navigationTitle("Review Terms of Service") + } label: { + Text("Review Terms") + } + } + } + } + .navigationTitle("Review Withdraw") + } +} + +struct WithdrawView: View { + @ObservedObject var model: WithdrawModel + @State var amount: String = "" + + var body: some View { + VStack { + NavigationLink("", isActive: $model.nav) { + PromptWithdrawView(model: model.promptModel) + .onDisappear { + self.model.nav = false + } + } + if self.model.loading { + ProgressView() + } else { + Button { + + } label: { + Text("Scan Taler QR Code") + } + Text("Or transfer manually:") + HStack { + TextField(model.exchange.currency, text: $amount) + } + Button { + // TODO: Handle when the user inputs a non-valid amount + model.getWithdrawDetails(amountStr: model.exchange.currency + ":" + amount) + } label: { + Text("Check Fees") + } + } + } + .navigationTitle("Withdraw") + } +}