commit 86364f0257c44882202e46d7b784c4cee4cab3dd parent 94b44a24be4795f103262a615a27ed05a64f9b5f Author: Marc Stibane <marc@taler.net> Date: Wed, 1 Feb 2023 00:29:56 +0100 App sources Diffstat:
| D | Taler/Model/BackendManager.swift | | | 30 | ------------------------------ |
| D | Taler/Model/BalancesModel.swift | | | 48 | ------------------------------------------------ |
| D | Taler/Model/ExchangeManager.swift | | | 69 | --------------------------------------------------------------------- |
| D | Taler/Model/PendingManager.swift | | | 49 | ------------------------------------------------- |
| D | Taler/Model/TransactionsModel.swift | | | 48 | ------------------------------------------------ |
| D | Taler/Model/WithdrawModel.swift | | | 179 | ------------------------------------------------------------------------------- |
| D | Taler/TalerApp.swift | | | 26 | -------------------------- |
| D | Taler/Views/BalancesView.swift | | | 106 | ------------------------------------------------------------------------------- |
| D | Taler/Views/ContentView.swift | | | 85 | ------------------------------------------------------------------------------- |
| D | Taler/Views/PendingView.swift | | | 68 | -------------------------------------------------------------------- |
| D | Taler/Views/SettingsView.swift | | | 252 | ------------------------------------------------------------------------------- |
| D | Taler/Views/WithdrawView.swift | | | 142 | ------------------------------------------------------------------------------- |
| D | Taler/WalletBackend.swift | | | 1167 | ------------------------------------------------------------------------------- |
| A | TalerWallet1/Backend/Transaction.swift | | | 314 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Backend/WalletBackendError.swift | | | 54 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Backend/WalletBackendRequest.swift | | | 434 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Backend/WalletCore.swift | | | 262 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Controllers/Controller.swift | | | 128 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Controllers/TalerWallet1App.swift | | | 81 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Helper/TalerDater.swift | | | 102 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Helper/TalerStrings.swift | | | 28 | ++++++++++++++++++++++++++++ |
| A | TalerWallet1/Helper/View+dismissTop.swift | | | 41 | +++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Model/ExchangeTestModel.swift | | | 138 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Model/WalletInitModel.swift | | | 86 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Model/WalletModel.swift | | | 55 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Quickjs/quickjs.swift | | | 81 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/BalanceRow.swift | | | 46 | ++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/BalancesModel.swift | | | 73 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/CurrenciesListView.swift | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/CurrencyView.swift | | | 58 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/PendingRow.swift | | | 61 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Balances/WalletEmptyView.swift | | | 44 | ++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Exchange/ExchangeListView.swift | | | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Exchange/ExchangeModel.swift | | | 115 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/HelperViews/AmountView.swift | | | 43 | +++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/HelperViews/Buttons.swift | | | 86 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/HelperViews/LoadingView.swift | | | 45 | +++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/HelperViews/TextFieldAlert.swift | | | 73 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Main/ContentView.swift | | | 92 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Main/ErrorView.swift | | | 31 | +++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Main/LaunchAnimationView.swift | | | 33 | +++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Main/SideBarView.swift | | | 110 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Payment/PaymentAcceptView.swift | | | 71 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Payment/PaymentURIModel.swift | | | 183 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Payment/PaymentURIView.swift | | | 65 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Pending/PendingModel.swift | | | 82 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Pending/PendingOpView.swift | | | 64 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Pending/PendingOpsListView.swift | | | 65 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Settings/SettingsItem.swift | | | 93 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Settings/SettingsView.swift | | | 139 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Transactions/TransactionDetail.swift | | | 79 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Transactions/TransactionRow.swift | | | 81 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Transactions/TransactionsListView.swift | | | 91 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Transactions/TransactionsModel.swift | | | 69 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/URLSheet.swift | | | 64 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Withdraw/WithdrawAcceptView.swift | | | 71 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Withdraw/WithdrawProgressView.swift | | | 45 | +++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Withdraw/WithdrawTOSView.swift | | | 96 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Withdraw/WithdrawURIModel.swift | | | 213 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | TalerWallet1/Views/Withdraw/WithdrawURIView.swift | | | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
60 files changed, 4569 insertions(+), 2269 deletions(-)
diff --git a/Taler/Model/BackendManager.swift b/Taler/Model/BackendManager.swift @@ -1,30 +0,0 @@ -/* - * 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 BackendManager: ObservableObject { - var backend: WalletBackend - - @Published var exchangeManager: ExchangeManager - @Published var pendingManager: PendingManager - - init() { - self.backend = try! WalletBackend() - self.exchangeManager = ExchangeManager(_backend: self.backend) - self.pendingManager = PendingManager(_backend: self.backend) - } -} diff --git a/Taler/Model/BalancesModel.swift b/Taler/Model/BalancesModel.swift @@ -1,48 +0,0 @@ -/* - * 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 { - var backend: WalletBackend - - @Published var loading: Bool = false - @Published var balances: [Balance]? - - init(backend: WalletBackend) { - self.backend = backend - } - - func getBalances() { - self.loading = true - let req = WalletBackendGetBalancesRequest() - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead - DispatchQueue.main.async { - self.loading = false - if let res = response { - self.balances = res.balances - } else { - // TODO: Handle error. - } - } - } - } - - func getTransactionsModel() -> TransactionsModel { - return TransactionsModel(backend: self.backend, currency: nil) - } -} diff --git a/Taler/Model/ExchangeManager.swift b/Taler/Model/ExchangeManager.swift @@ -1,69 +0,0 @@ -/* - * 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 -import taler_swift - -typealias ExchangeItem = WalletBackendListExchanges.ExchangeListItem - -class ExchangeManager: ObservableObject { - var backend: WalletBackend - - @Published var loading: Bool - @Published var exchanges: [ExchangeItem]? - - init(_backend: WalletBackend) { - self.backend = _backend - self.loading = false - self.exchanges = nil - } - - func updateList() { - let listRequest = WalletBackendListExchanges() - backend.sendFormattedRequest(request: listRequest) { response, err in - // TODO: Use Combine instead. - DispatchQueue.main.async { - self.loading = false - if let result = response { - self.exchanges = result.exchanges - } else { - // TODO: Show error. - } - } - } - self.loading = true - } - - func add(url: String) { - let addRequest = WalletBackendAddExchangeRequest(exchangeBaseUrl: url) - backend.sendFormattedRequest(request: addRequest) { response, err in - // TODO: Use Combine instead. - DispatchQueue.main.async { - self.loading = false - if let _ = response { - self.updateList() - } else { - // TODO: Show error. - } - } - } - self.loading = true - } - - func withdraw(exchange: ExchangeItem) -> WithdrawModel { - return WithdrawModel(backend: self.backend, exchange: exchange) - } -} diff --git a/Taler/Model/PendingManager.swift b/Taler/Model/PendingManager.swift @@ -1,49 +0,0 @@ -/* - * 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 -import AnyCodable - -class PendingManager: ObservableObject { - var backend: WalletBackend - - @Published var loading: Bool - @Published var items: [String]? - - init(_backend: WalletBackend) { - self.backend = _backend - self.loading = false - self.items = nil - } - - func update() { - let req = WalletBackendPendingRequest() - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead. - DispatchQueue.main.async { - self.loading = false - if let x = response { - self.items = x.pendingOperations.map({ op in - let encoded = try! JSONEncoder().encode(op) - let str = String(data: encoded, encoding: .utf8)! - return str - }) - } - } - } - self.loading = true - } -} diff --git a/Taler/Model/TransactionsModel.swift b/Taler/Model/TransactionsModel.swift @@ -1,48 +0,0 @@ -/* - * 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 TransactionsModel: ObservableObject { - var backend: WalletBackend - var currency: String? - - @Published var loading: Bool = false - @Published var transactions: [Transaction]? - - init(backend: WalletBackend, currency: String?) { - self.backend = backend - self.currency = currency - } - - func loadTransactions(searchString: String? = nil) { - self.loading = true - let req = WalletBackendGetTransactionsRequest(currency: self.currency, - search: searchString) - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead - DispatchQueue.main.async { - self.loading = false - if let res = response { - print("x") - self.transactions = res.transactions - } else { - // TODO: Handle error. - } - } - } - } -} diff --git a/Taler/Model/WithdrawModel.swift b/Taler/Model/WithdrawModel.swift @@ -1,179 +0,0 @@ -/* - * 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 -import taler_swift - -func paytoUriGetIban(uri: String) -> String { - let url = URL(string: uri)! - return url.lastPathComponent -} - -func paytoUriGetSubject(uri: String) -> String { - let url = URLComponents(string: uri)! - return url.queryItems!.first(where: { item in - item.name == "message" - })!.value!.removingPercentEncoding!.replacingOccurrences(of: "+", with: " ") -} - -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) - } - - 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 - 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.promptModel = PromptWithdrawModel(backend: backend, exchange: exchange) - } - - func getWithdrawDetails(amountStr: String) { - self.loading = true - do { - let amount = try Amount(fromString: amountStr) - let req = WalletBackendGetWithdrawalDetailsForAmountRequest(exchangeBaseUrl: exchange.exchangeBaseUrl, - amount: amount) - backend.sendFormattedRequest(request: req) { response, err in - // TODO: Use Combine instead. - DispatchQueue.main.async { - if let res = response { - self.details = res - self.promptModel.loadDetails(res) - if res.tosAccepted { - self.loading = false - self.nav = true - } else { - self.getTos() - } - } else { - // TODO: Show error. - self.loading = false - } - } - } - } catch { - // TODO: Show error. - self.loading = false - } - } - - 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 { - self.promptModel.tosDetails = res - self.loading = false - self.nav = true - } else { - // TODO: Show error. - self.loading = false - } - } - } - } -} diff --git a/Taler/TalerApp.swift b/Taler/TalerApp.swift @@ -1,26 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2021 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 - -@main -struct TalerApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/Taler/Views/BalancesView.swift b/Taler/Views/BalancesView.swift @@ -1,106 +0,0 @@ -/* - * 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 TransactionsView: View { - @ObservedObject var model: TransactionsModel - - var body: some View { - VStack { - if model.transactions == nil { - ProgressView() - .onAppear { - model.loadTransactions() - } - } else if model.loading { - ProgressView() - } else { - List(model.transactions!, id: \.self) { tx in - VStack { - Text("Transaction: \(tx.transactionId)") - } - } - Text("Loaded") - /*VStack { - Text("Balances") - NavigationLink { - TransactionsView(model: self.balancesModel.getTransactionsModel()) - } label: { - Text("Transactions") - } - - } - .padding(16) - .navigationTitle("Balances") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }) - )*/ - } - } - .navigationTitle("Transactions") - } -} - -struct BalancesView: View { - @ObservedObject var balancesModel: BalancesModel - @EnvironmentObject var backend: BackendManager - var showSidebar: () -> Void - - var body: some View { - NavigationView { - if balancesModel.balances == nil { - ProgressView() - .navigationTitle("Balances") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }) - ) - .onAppear { - balancesModel.getBalances() - } - } else if balancesModel.loading { - ProgressView() - .navigationTitle("Balances") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }) - ) - } else { - VStack { - Text("Balances") - NavigationLink { - TransactionsView(model: self.balancesModel.getTransactionsModel()) - } label: { - Text("Transactions") - } - - } - .padding(16) - .navigationTitle("Balances") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }) - ) - } - } - } -} diff --git a/Taler/Views/ContentView.swift b/Taler/Views/ContentView.swift @@ -1,85 +0,0 @@ -/* - * 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 SidebarItem { - var name: String - var view: AnyView -} - -struct ContentView: View { - @StateObject var backend: BackendManager = BackendManager() - - @State var sidebarVisible: Bool = false - var views: [SidebarItem] {[ - 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))), - SidebarItem(name: "Pending Operations", - view: AnyView(PendingView(_showSidebar: { - self.sidebarVisible = true - }, pending: backend.pendingManager).environmentObject(backend))) - ]} - @State var currentView: Int = 0 - - var body: some View { - ZStack(alignment: .leading) { - - views[currentView].view - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - - VStack { - Spacer() - - Button { - self.sidebarVisible = false - } label: { - Text("Close") - } - Divider() - - ForEach(0..<views.count, id: \.self) { i in - Button { - self.sidebarVisible = false - self.currentView = i - } label: { - Text(views[i].name) - } - Divider() - } - - Spacer() - } - .background(Color.gray) - .frame(width: 100, alignment: .center) - .offset(x: sidebarVisible ? 0 : -100) - .animation(.easeInOut, value: sidebarVisible) - .ignoresSafeArea() - } - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/Taler/Views/PendingView.swift b/Taler/Views/PendingView.swift @@ -1,68 +0,0 @@ -/* - * 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 PendingView: View { - @ObservedObject var pendingManager: PendingManager - - var showSidebar: () -> Void - var body: some View { - NavigationView { - if pendingManager.items == nil { - ProgressView() - .navigationTitle("Pending") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - })) - .onAppear { - pendingManager.update() - } - } else if pendingManager.loading { - ProgressView() - .navigationTitle("Pending") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - })) - } else { - let items = pendingManager.items! - List(items, id: \.self) { item in - VStack { - Text(item) - .font(.system(size: 14, design: .monospaced)) - } - } - .navigationTitle("Pending") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }), - trailing: Button(action: { - pendingManager.update() - }, label: { - Image(systemName: "arrow.clockwise") - })) - } - } - } - - init(_showSidebar: @escaping () -> Void, pending: PendingManager) { - self.showSidebar = _showSidebar - self.pendingManager = pending - } -} diff --git a/Taler/Views/SettingsView.swift b/Taler/Views/SettingsView.swift @@ -1,252 +0,0 @@ -/* - * 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 TextInputPopup: ViewModifier { - @State var exchangeUrl: String = "https://" - var onCancel: () -> Void - var onOk: (String) -> Void - - init(cancel: @escaping () -> Void, ok: @escaping (String) -> Void) { - self.onCancel = cancel - self.onOk = ok - } - - func body(content: Content) -> some View { - return content - .overlay( - VStack { - Text("Add Exchange") - TextField("Exchange URL", text: $exchangeUrl) - HStack { - Button { - self.onCancel() - } label: { - Text("Cancel") - } - Button { - self.onOk(exchangeUrl) - } label: { - Text("Ok") - } - } - } - .padding(8) - .frame(width: UIScreen.main.bounds.width - 100, height: 150, alignment: .center) - .background(Color.green) - .cornerRadius(8) - , alignment: .center) - .animation(.easeIn) - } -} - -extension View { - func textInputPopup(cancel: @escaping () -> Void, ok: @escaping (String) -> Void, showing: Bool) -> some View { - if showing { - return AnyView(modifier(TextInputPopup(cancel: cancel, ok: ok))) - } else { - return AnyView(self) - } - } -} - -struct ExchangeListView: View { - @ObservedObject var exchangeManager: ExchangeManager - @State var showPopup: Bool = false - - var body: some View { - if exchangeManager.exchanges == nil { - ProgressView() - .navigationTitle("Exchanges") - .onAppear { - exchangeManager.updateList() - } - } else if exchangeManager.loading { - ProgressView() - .navigationTitle("Exchanges") - } else { - let exchanges = exchangeManager.exchanges! - if exchanges.count == 0 { - Text("No Exchanges") - .navigationTitle("Exchanges") - .navigationBarItems(trailing: Button(action: { - withAnimation { - showPopup = true - } - }, label: { - Image(systemName: "plus") - })) - .textInputPopup(cancel: { - self.showPopup = false - }, ok: { exchangeUrl in - self.showPopup = false - exchangeManager.add(url: exchangeUrl) - print(exchangeUrl) - }, showing: showPopup) - } else { - List(exchanges, id: \.self) { exchange in - VStack { - Text(exchange.exchangeBaseUrl) - .frame(maxWidth: .infinity) - Text("Currency: " + exchange.currency) - .frame(maxWidth: .infinity) - NavigationLink { - WithdrawView(model: exchangeManager.withdraw(exchange: exchange)) - } label: { - Text("Withdraw") - } - } - } - .navigationTitle("Exchanges") - .navigationBarItems(trailing: Button(action: { - withAnimation { - showPopup = true - } - }, label: { - Image(systemName: "plus") - })) - .textInputPopup(cancel: { - self.showPopup = false - }, ok: { exchangeUrl in - self.showPopup = false - exchangeManager.add(url: exchangeUrl) - print(exchangeUrl) - }, showing: showPopup) - } - } - } -} - -/* - * Exchanges - * Manage list of exchanges known to this wallet - * - * Backup - * Last backup: 5 hr. ago - * - * Developer Mode [toggle] - * Shows more information intended for debugging - * - * Withdraw TESTKUDOS - * Get money for testing - * - * Debug log - * View/send internal log - * - * App Version - * v0.9.0-dev.11 (fdroid 11) - * - * Wallet Core Version - * v0.9.0-dev.11 - * - * Supported Exchange Versions - * 12:0:0 - * - * Supported Merchant Versions - * 2:0:1 - * - * Reset Wallet (dangerous!) - * Throws away your money - */ - -struct SettingsItem<Content: View>: View { - var name: String - var description: String? - var content: () -> Content - - init(name: String, description: String? = nil, @ViewBuilder content: @escaping () -> Content) { - self.name = name - self.description = description - self.content = content - } - - var body: some View { - HStack { - Image(systemName: "line.3.horizontal") - VStack { - Text(name) - .frame(maxWidth: .infinity, alignment: .leading) - .font(.title2) - if let desc = description { - Text(desc) - .frame(maxWidth: .infinity, alignment: .leading) - .font(.caption) - } - } - content() - } - .padding([.bottom], 8) - } -} - -struct SettingsView: View { - @EnvironmentObject var backend: BackendManager - @AppStorage("developerMode") var developerMode: Bool = false - - var showSidebar: () -> Void - var body: some View { - NavigationView { - VStack { - SettingsItem(name: "Exchanges", description: "Manage list of exchanges known to this wallet") { - NavigationLink { - ExchangeListView(exchangeManager: backend.exchangeManager) - } label: { - Text("View") - } - } - SettingsItem(name: "Developer Mode", description: "Shows more information intended for debugging") { - Toggle(isOn: $developerMode) { } - } - if developerMode { - SettingsItem(name: "App Version") { - Text("v0.9.0-dev.11") - } - SettingsItem(name: "Wallet Core Version") { - Text("v0.9.0-dev.11") - } - SettingsItem(name: "Supported Exchange Versions") { - Text("12:0:0") - } - SettingsItem(name: "Supported Merchant Versions") { - Text("2:0:1") - } - } - Spacer() - } - .padding(16) - .navigationTitle("Settings") - .navigationBarItems( - leading: Button(action: self.showSidebar, label: { - Image(systemName: "line.3.horizontal") - }) - ) - } - } - - init(_showSidebar: @escaping () -> Void) { - self.showSidebar = _showSidebar - } -} - -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView { - - } - } -} diff --git a/Taler/Views/WithdrawView.swift b/Taler/Views/WithdrawView.swift @@ -1,142 +0,0 @@ -/* - * 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") - } -} diff --git a/Taler/WalletBackend.swift b/Taler/WalletBackend.swift @@ -1,1167 +0,0 @@ -/* - * 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 -import iono -import taler_swift -import AnyCodable - -/// Information supplied by the backend describing an error. -struct WalletBackendResponseError: Decodable { - /// Numeric error code defined defined in the GANA gnu-taler-error-codes registry. - var talerErrorCode: Int - - /// English description of the error code. - var talerErrorHint: String - - /// English diagnostic message that can give details for the instance of the error. - var message: String - - /// Error details, type depends on `talerErrorCode`. - var details: Data? -} - -/// A request sent to the wallet backend. -struct WalletBackendRequest: Encodable { - /// The operation name of the request. - var operation: String - - /// The body of the request as JSON. - var args: AnyEncodable -} - -protocol WalletBackendFormattedRequest { - associatedtype Args: Encodable - associatedtype Response: Decodable - - func operation() -> String - func args() -> Args -} - -fileprivate struct WalletBackendInitRequest: WalletBackendFormattedRequest { - var persistentStoragePath: String - - struct Args: Encodable { - var persistentStoragePath: String - } - - struct Response: Codable { - struct SupportedProtocolVersions: Codable { - var exchange: String - var merchant: String - } - var supportedProtocolVersions: SupportedProtocolVersions - enum CodingKeys: String, CodingKey { - case supportedProtocolVersions = "supported_protocol_versions" - } - } - - func operation() -> String { - return "init" - } - - func args() -> Args { - return Args(persistentStoragePath: persistentStoragePath) - } -} - -/// An balance on a wallet. -struct Balance: Decodable { - var available: Amount - var pendingIncoming: Amount - var pendingOutgoing: Amount - var requiresUserInput: Bool -} - -/// A request to get the balances held in the wallet. -struct WalletBackendGetBalancesRequest: WalletBackendFormattedRequest { - struct Args: Encodable { - - } - - struct Response: Decodable { - var balances: [Balance] - } - - func operation() -> String { - return "getBalances" - } - - func args() -> Args { - return Args() - } -} - -/// A billing or mailing location. -struct Location: Codable { - var country: String? - var country_subdivision: String? - var district: String? - var town: String? - var town_location: String? - var post_code: String? - var street: String? - var building_name: String? - var building_number: String? - var address_lines: [String]? -} - -/// Information identifying a merchant. -struct Merchant: Codable { - var name: String - var address: Location? - var jurisdiction: Location? -} - -/// A tax made on a payment. -struct Tax: Codable { - var name: String - var tax: Amount -} - -/// A product being purchased from a merchant. -struct Product: Codable { - var product_id: String? - var description: String - // description_i18n? - var quantity: Int - var unit: String - var price: Amount? - var image: String // URL to a product image - var taxes: [Tax]? - var delivery_date: Timestamp? -} - -/// Brief information about an order. -struct OrderShortInfo: Codable { - var orderId: String - var merchant: Merchant - var summary: String - // summary_i18n? - var products: [Product] - var fulfillmentUrl: String? - var fulfillmentMessage: String? - // fulfillmentMessage_i18n? -} - -enum TransactionTypeError: Error { - case unknownTypeError -} - -/// Different types of transactions. -enum TransactionType: Codable { - case withdrawal - case payment - case refund - case tip - case refresh - - init(from decoder: Decoder) throws { - let value = try decoder.singleValueContainer() - let str = try value.decode(String.self) - let codingNames = [ - "TransactionWithdrawal" : TransactionType.withdrawal, - "TransactionPayment" : TransactionType.payment, - "TransactionRefund" : TransactionType.refund, - "TransactionTip" : TransactionType.tip, - "TransactionRefresh" : TransactionType.refresh - ] - if let type = codingNames[str] { - self = type - } else { - throw TransactionTypeError.unknownTypeError - } - } - - func encode(to encoder: Encoder) throws { - var value = encoder.singleValueContainer() - switch self { - case .withdrawal: - try value.encode("TransactionWithdrawal") - case .payment: - try value.encode("TransactionPayment") - case .refund: - try value.encode("TransactionRefund") - case .tip: - try value.encode("TransactionTip") - case .refresh: - try value.encode("TransactionRefresh") - } - } -} - -enum TransactionDecodingError: Error { - case invalidStringValue -} - -/// Details for a manual withdrawal. -struct ManualWithdrawalDetails: Codable { - /// The payto URIs that the exchange supports. - var exchangePaytoUris: [String] - - /// The public key of the newly created reserve. - var reservePub: String -} - -/// Details for a bank-integrated withdrawal. -struct BankIntegratedWithdrawalDetails: Codable { - /// Whether the bank has confirmed the withdrawal. - var confirmed: Bool - - /// URL for user-initiated confirmation - var bankConfirmationUrl: String? -} - -/// A withdrawal transaction. -struct TransactionWithdrawal: Decodable { - enum WithdrawalDetails { - case manual(ManualWithdrawalDetails) - case bankIntegrated(BankIntegratedWithdrawalDetails) - } - - /// The exchange that was withdrawn from. - var exchangeBaseUrl: String - - /// The amount of the withdrawal, including fees. - var amountRaw: Amount - - /// The amount that will be added to the withdrawer's account. - var amountEffective: Amount - - /// The details of the withdrawal. - var withdrawalDetails: WithdrawalDetails - - init(from decoder: Decoder) throws { - enum CodingKeys: String, CodingKey { - case exchangeBaseUrl - case amountRaw - case amountEffective - case withdrawalDetails - case type - case exchangePaytoUris - case reservePub - case confirmed - case bankConfirmationUrl - } - - let value = try decoder.container(keyedBy: CodingKeys.self) - self.exchangeBaseUrl = try value.decode(String.self, forKey: .exchangeBaseUrl) - self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) - self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) - - let detail = try value.nestedContainer(keyedBy: CodingKeys.self, forKey: .withdrawalDetails) - let detailType = try detail.decode(String.self, forKey: .type) - if detailType == "manual-transfer" { - let paytoUris = try detail.decode([String].self, forKey: .exchangePaytoUris) - let reservePub = try detail.decode(String.self, forKey: .reservePub) - let manual = ManualWithdrawalDetails(exchangePaytoUris: paytoUris, reservePub: reservePub) - self.withdrawalDetails = .manual(manual) - } else if detailType == "taler-bank-integration-api" { - let confirmed = try detail.decode(Bool.self, forKey: .confirmed) - var bankConfirmationUrl: String? = nil - if detail.contains(.bankConfirmationUrl) { - bankConfirmationUrl = try detail.decode(String.self, forKey: .bankConfirmationUrl) - } - let bankDetails = BankIntegratedWithdrawalDetails(confirmed: confirmed, bankConfirmationUrl: bankConfirmationUrl) - self.withdrawalDetails = .bankIntegrated(bankDetails) - } else { - throw TransactionDecodingError.invalidStringValue - } - } -} - -/// A payment transaction. -struct TransactionPayment: Codable { - /// Additional information about the payment. - // TODO - - /// An identifier for the payment. - var proposalId: String - - /// The current status of the payment. - // TODO - - /// The amount that must be paid. - var amountRaw: Amount - - /// The amount that was paid. - var amountEffective: Amount -} - -/// A refund transaction. -struct TransactionRefund: Codable { - /// Identifier for the refund. - var refundedTransactionId: String - - /// Additional information about the refund - // TODO - - /// The amount that couldn't be applied because refund permissions expired. - var amountInvalid: Amount - - /// The amount refunded by the merchant. - var amountRaw: Amount - - /// The amount paid to the wallet after fees. - var amountEffective: Amount -} - -/// A tip transaction. -struct TransactionTip: Codable { - /// The current status of the tip. - // TODO - - /// The exchange that the tip will be withdrawn from - var exchangeBaseUrl: String - - /// More information about the merchant sending the tip. - // TODO - - /// The raw amount of the tip without fees. - var amountRaw: Amount - - /// The amount added to the recipient's wallet. - var amountEffective: Amount -} - -/// A refresh transaction. -struct TransactionRefresh: Codable { - /// The exchange that the coins are refreshed with. - var exchangeBaseUrl: String - - /// The raw amount to refresh. - var amountRaw: Amount - - /// The amount to be paid as fees for the refresh. - var amountEffective: Amount -} - -/// A wallet transaction. -struct Transaction: Decodable, Hashable { - enum TransactionDetail { - case withdrawal(TransactionWithdrawal) - } - - var transactionId: String - var timestamp: Timestamp - var pending: Bool - var error: AnyCodable? - var amountRaw: Amount - var amountEffective: Amount - var detail: TransactionDetail - - init(from decoder: Decoder) throws { - enum CodingKeys: String, CodingKey { - case transactionId - case timestamp - case pending - case error - case amountRaw - case amountEffective - case type - } - - let value = try decoder.container(keyedBy: CodingKeys.self) - self.transactionId = try value.decode(String.self, forKey: .transactionId) - self.timestamp = try value.decode(Timestamp.self, forKey: .timestamp) - self.pending = try value.decode(Bool.self, forKey: .pending) - if value.contains(.error) { - self.error = try value.decode(AnyCodable.self, forKey: .error) - } - self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) - self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) - - let type = try value.decode(String.self, forKey: .type) - if type == "withdrawal" { - let withdrawDetail = try TransactionWithdrawal.init(from: decoder) - self.detail = .withdrawal(withdrawDetail) - } else { - throw TransactionDecodingError.invalidStringValue - } - } - - static func == (lhs: Transaction, rhs: Transaction) -> Bool { - return lhs.transactionId == rhs.transactionId - } - - func hash(into hasher: inout Hasher) { - transactionId.hash(into: &hasher) - } -} - -/// A request to get the transactions in the wallet's history. -struct WalletBackendGetTransactionsRequest: WalletBackendFormattedRequest { - var currency: String? - var search: String? - - struct Args: Encodable { - var currency: String? - var search: String? - } - - struct Response: Decodable { - var transactions: [Transaction] - } - - func operation() -> String { - return "getTransactions" - } - - func args() -> Args { - return Args(currency: currency, search: search) - } -} - -/// A request to delete a wallet transaction by ID. -struct WalletBackendDeleteTransactionRequest: WalletBackendFormattedRequest { - var transactionId: String - - struct Args: Encodable { - var transactionId: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "deleteTransaction" - } - - func args() -> Args { - return Args(transactionId: transactionId) - } -} - -/// A request to process a refund. -struct WalletBackendApplyRefundRequest: WalletBackendFormattedRequest { - var talerRefundUri: String - - struct Args: Encodable { - var talerRefundUri: String - } - - struct Response: Decodable { - var contractTermsHash: String - var amountEffectivePaid: Amount - var amountRefundGranted: Amount - var amountRefundGone: Amount - var pendingAtExchange: Bool - var info: OrderShortInfo - } - - func operation() -> String { - return "applyRefund" - } - - func args() -> Args { - return Args(talerRefundUri: talerRefundUri) - } -} - -/// A request to list exchanges. -struct WalletBackendListExchanges: WalletBackendFormattedRequest { - struct Args: Encodable { - - } - - struct ExchangeListItem: Decodable, Hashable { - var exchangeBaseUrl: String - var currency: String - var paytoUris: [String] - - var name: String { - let url = URL(string: exchangeBaseUrl)! - return url.host! - } - } - - struct Response: Decodable { - var exchanges: [ExchangeListItem] - } - - func operation() -> String { - return "listExchanges" - } - - func args() -> Args { - return Args() - } -} - -/// A request to add an exchange. -struct WalletBackendAddExchangeRequest: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - - struct Args: Encodable { - var exchangeBaseUrl: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "addExchange" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl) - } -} - -/// A request to force update an exchange. -struct WalletBackendForceUpdateRequest: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - - struct Args: Encodable { - var exchangeBaseUrl: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "addRequest" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl) - } -} - -/// A request to query an exchange's terms of service. -struct WalletBackendGetExchangeTermsOfService: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - - struct Args: Encodable { - var exchangeBaseUrl: String - } - - struct Response: Decodable { - var content: String - var currentEtag: String - var acceptedEtag: String? - } - - func operation() -> String { - return "getExchangeTos" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl) - } -} - -/// A request to mark an exchange's terms of service as accepted. -struct WalletBackendSetExchangeTermsOfServiceAccepted: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - var etag: String - - struct Args: Encodable { - var exchangeBaseUrl: String - var etag: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "setExchangeTosAccepted" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl, etag: etag) - } -} - -struct ExchangeListItem: Codable { - var exchangeBaseUrl: String - var currency: String - var paytoUris: [String] -} - -/// A request to get an exchange's withdrawal details. -struct WalletBackendGetWithdrawalDetailsForURIRequest: WalletBackendFormattedRequest { - var talerWithdrawUri: String - - struct Args: Encodable { - var talerWithdrawUri: String - } - - struct Response: Decodable { - var amount: Amount - var defaultExchangeBaseUrl: String? - var possibleExchanges: [ExchangeListItem] - } - - func operation() -> String { - return "getWithdrawalDetailsForUri" - } - - func args() -> Args { - return Args(talerWithdrawUri: talerWithdrawUri) - } -} - -/// A request to get an exchange's withdrawal details. -struct WalletBackendGetWithdrawalDetailsForAmountRequest: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - var amount: Amount - - struct Args: Encodable { - var exchangeBaseUrl: String - var amount: Amount - } - - struct Response: Decodable { - var tosAccepted: Bool - var amountRaw: Amount - var amountEffective: Amount - } - - func operation() -> String { - return "getWithdrawalDetailsForAmount" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount) - } -} - -/// A request to accept a bank-integrated withdrawl. -struct WalletBackendAcceptBankIntegratedWithdrawalRequest: WalletBackendFormattedRequest { - var talerWithdrawUri: String - var exchangeBaseUrl: String - - struct Args: Encodable { - var talerWithdrawUri: String - var exchangeBaseUrl: String - } - - struct Response: Decodable { - var bankConfirmationUrl: String? - } - - func operation() -> String { - return "acceptWithdrawal" - } - - func args() -> Args { - return Args(talerWithdrawUri: talerWithdrawUri, exchangeBaseUrl: exchangeBaseUrl) - } -} - -/// A request to accept a manual withdrawl. -struct WalletBackendAcceptManualWithdrawalRequest: WalletBackendFormattedRequest { - var exchangeBaseUrl: String - var amount: Amount - - struct Args: Encodable { - var exchangeBaseUrl: String - var amount: Amount - } - - struct Response: Decodable { - var exchangePaytoUris: [String] - } - - func operation() -> String { - return "acceptManualWithdrawal" - } - - func args() -> Args { - return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount) - } -} - -/// A request to deposit funds. -struct WalletBackendCreateDepositGroupRequest: WalletBackendFormattedRequest { - var depositePayToUri: String - var amount: Amount - - struct Args: Encodable { - var depositPayToUri: String - var amount: Amount - } - - struct Response: Decodable { - var depositGroupId: String - } - - func operation() -> String { - return "createDepositGroup" - } - - func args() -> Args { - return Args(depositPayToUri: depositePayToUri, amount: amount) - } -} - -/// A request to get information about a payment request. -struct WalletBackendPreparePayRequest: WalletBackendFormattedRequest { - var talerPayUri: String - - struct Args: Encodable { - var talerPayUri: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "preparePay" - } - - func args() -> Args { - return Args(talerPayUri: talerPayUri) - } -} - -/// A request to confirm a payment. -struct WalletBackendConfirmPayRequest: WalletBackendFormattedRequest { - var proposalId: String - - struct Args: Encodable { - var proposalId: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "abortFailedPayWithRefund" - } - - func args() -> Args { - return Args(proposalId: proposalId) - } -} - -/// A request to prepare a tip. -struct WalletBackendPrepareTipRequest: WalletBackendFormattedRequest { - var talerTipUri: String - - struct Args: Encodable { - var talerTipUri: String - } - - struct Response: Decodable { - var walletTipId: String - var accepted: Bool - var tipAmountRaw: Amount - var tipAmountEffective: Amount - var exchangeBaseUrl: String - var expirationTimestamp: Timestamp - } - - func operation() -> String { - return "prepareTip" - } - - func args() -> Args { - return Args(talerTipUri: talerTipUri) - } -} - -/// A request to accept a tip. -struct WalletBackendAcceptTipRequest: WalletBackendFormattedRequest { - var walletTipId: String - - struct Args: Encodable { - var walletTipId: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "acceptTip" - } - - func args() -> Args { - return Args(walletTipId: walletTipId) - } -} - -/// A request to abort a failed payment. -struct WalletBackendAbortFailedPaymentRequest: WalletBackendFormattedRequest { - var proposalId: String - - struct Args: Encodable { - var proposalId: String - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "confirmPay" - } - - func args() -> Args { - return Args(proposalId: proposalId) - } -} - -/// A request to withdraw a balance from the TESTKUDOS environment. -struct WalletBackendWithdrawTestkudosRequest: WalletBackendFormattedRequest { - struct Args: Encodable { - - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "withdrawTestkudos" - } - - func args() -> Args { - return Args() - } -} - -/// A request to add a test balance to the wallet. -struct WalletBackendWithdrawTestBalance: WalletBackendFormattedRequest { - var amount: Amount - var bankBaseUrl: String - var exchangeBaseUrl: String - - struct Args: Encodable { - var amount: Amount - var bankBaseUrl: String - var exchangeBaseUrl: String - } - typealias Response = String - - func operation() -> String { - return "withdrawTestBalance" - } - - func args() -> Args { - return Args(amount: amount, bankBaseUrl: bankBaseUrl, exchangeBaseUrl: exchangeBaseUrl) - } -} - -struct IntegrationTestArgs: Codable { - var exchangeBaseUrl: String - var bankBaseUrl: String - var merchantBaseUrl: String - var merchantApiKey: String - var amountToWithdraw: String - var amountToSpend: String -} - -/// A request to run a basic integration test. -struct WalletBackendRunIntegrationTestRequest: WalletBackendFormattedRequest { - var integrationTestArgs: IntegrationTestArgs - - typealias Args = IntegrationTestArgs - - struct Response: Decodable { - - } - - func operation() -> String { - return "runIntegrationTest" - } - - func args() -> Args { - return integrationTestArgs - } -} - -struct TestPayArgs: Codable { - var merchantBaseUrl: String - var merchantApiKey: String - var amount: String - var summary: String -} - -/// A request to make a test payment. -struct WalletBackendTestPayRequest: WalletBackendFormattedRequest { - var testPayArgs: TestPayArgs - - typealias Args = TestPayArgs - - struct Response: Decodable { - - } - - func operation() -> String { - return "testPay" - } - - func args() -> Args { - return testPayArgs - } -} - -struct Coin: Codable { - var denom_pub: String - var denom_pub_hash: String - var denom_value: String - var coin_pub: String - var exchange_base_url: String - var remaining_value: String - var refresh_parent_coin_pub: String - var withdrawal_reserve_pub: String - var coin_suspended: Bool -} - -/// A request to dump all coins to JSON. -struct WalletBackendDumpCoinsRequest: WalletBackendFormattedRequest { - struct Args: Encodable { - - } - - struct Response: Decodable { - var coins: [Coin] - } - - func operation() -> String { - return "dumpCoins" - } - - func args() -> Args { - return Args() - } -} - -/// A request to suspend or unsuspend a coin. -struct WalletBackendSuspendCoinRequest: WalletBackendFormattedRequest { - var coinPub: String - var suspended: Bool - - struct Args: Encodable { - var coinPub: String - var suspended: Bool - } - - struct Response: Decodable { - - } - - func operation() -> String { - return "setCoinSuspended" - } - - func args() -> Args { - return Args(coinPub: coinPub, suspended: suspended) - } -} - -typealias PendingOperation = AnyCodable - -/// A request to list the backend's currently pending operations. -struct WalletBackendPendingRequest: WalletBackendFormattedRequest { - struct Args: Encodable { - - } - - struct Response: Decodable { - var pendingOperations: [PendingOperation] - } - - func operation() -> String { - return "getPendingOperations" - } - - func args() -> Args { - Args() - } -} - -/// Errors for `WalletBackend`. -enum WalletBackendError: Error { - /// An error that prevented the wallet from being initialized occurred. - case initializationError - case serializationError - case deserializationError -} - -/// Delegate for the wallet backend. -protocol WalletBackendDelegate { - /// Called when the backend interface receives a message it does not know how to handle. - func walletBackendReceivedUnknownMessage(_ walletBackend: WalletBackend, message: String) -} - -/// An interface to the wallet backend. -class WalletBackend: IonoMessageHandler { - private var iono: Iono - private var requestsMade: UInt - private var backendReady: Bool - private var backendReadyCondition: NSCondition - private var requests: [UInt : (AnyCodable?, WalletBackendResponseError?) -> Void] = [:] - var delegate: WalletBackendDelegate? - - private struct FullRequest: Encodable { - let operation: String - let id: UInt - let args: AnyEncodable - } - - private struct FullResponse: Decodable { - let type: String - let operation: String - let id: UInt - let result: AnyCodable - } - - private struct FullError: Decodable { - let type: String - let operation: String - let id: UInt - let error: WalletBackendResponseError - } - - init() throws { - iono = Iono() - requestsMade = 0 - self.backendReady = false - self.backendReadyCondition = NSCondition() - - iono.messageHandler = self - - let js_path = URL(fileURLWithPath: Bundle.main.path(forResource: "taler-wallet-embedded", ofType: "js")!) - do { - let js = try String(contentsOf: js_path, encoding: .utf8) - iono.putModuleCode(modName: "@gnu-taler/taler-wallet-embedded", code: js) - iono.evalNodeCode(source: "require('iono');") - iono.evalNodeCode(source: "tw = require('@gnu-taler/taler-wallet-embedded');") - iono.evalNodeCode(source: "tw.installNativeWalletListener();") - } catch { - throw WalletBackendError.initializationError - } - - // Send the init message - let documentUrls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - if (documentUrls.count > 0) { - var storageDir = documentUrls[0] - storageDir.appendPathComponent("talerwalletdb-v30", isDirectory: false) - storageDir.appendPathExtension("json") - sendFormattedRequest(request: WalletBackendInitRequest(persistentStoragePath: storageDir.path), completionHandler: { (resp: WalletBackendInitRequest.Response?, err: WalletBackendResponseError?) in - self.backendReady = true - self.backendReadyCondition.broadcast() - }) - } - - waitUntilReady() - } - - deinit { - iono.waitStopped() - } - - func waitUntilReady() { - backendReadyCondition.lock() - while (!self.backendReady) { - backendReadyCondition.wait() - } - backendReadyCondition.unlock() - } - - func handleMessage(message: String) { - print(message) - do { - guard let messageData = message.data(using: .utf8) else { throw WalletBackendError.deserializationError } - let data = try JSONSerialization.jsonObject(with: messageData, options: .allowFragments) as? [String : Any] - if let responseData = data { - let type = (responseData["type"] as? String) ?? "" - if type == "response" { - guard let id = responseData["id"] as? UInt else { throw WalletBackendError.deserializationError } - guard let request = requests[id] else { throw WalletBackendError.deserializationError } - do { - let decoded = try JSONDecoder().decode(FullResponse.self, from: messageData) - request(decoded.result, nil) - } catch { - request(nil, WalletBackend.parseResponseError()) - } - requests[id] = nil - } else if type == "tunnelHttp" { - // TODO: Handle - } else if type == "notification" { - // TODO: Handle - } else if type == "error" { - guard let id = responseData["id"] as? UInt else { throw WalletBackendError.deserializationError } - guard let request = requests[id] else { throw WalletBackendError.deserializationError } - do { - let decoded = try JSONDecoder().decode(FullError.self, from: messageData) - request(nil, decoded.error) - } catch { - request(nil, WalletBackend.parseFailureError()) - } - requests[id] = nil - } else { - throw WalletBackendError.deserializationError - } - } - } catch { - self.delegate?.walletBackendReceivedUnknownMessage(self, message: message) - } - } - - func sendRequest(request: WalletBackendRequest, completionHandler: @escaping (AnyCodable?, WalletBackendResponseError?) -> Void) { - /* Encode the request and send it to the backend. */ - do { - let full = FullRequest(operation: request.operation, id: requestsMade, args: request.args) - let encoded = try JSONEncoder().encode(full) - guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError } - requests[full.id] = completionHandler - requestsMade += 1 - iono.sendMessage(message: jsonString) - } catch { - completionHandler(nil, WalletBackend.serializeRequestError()); - } - } - - func sendFormattedRequest<T: WalletBackendFormattedRequest>(request: T, completionHandler: @escaping (T.Response?, WalletBackendResponseError?) -> Void) { - let reqData = WalletBackendRequest(operation: request.operation(), args: AnyEncodable(request.args())) - sendRequest(request: reqData) { (result: AnyCodable?, err: WalletBackendResponseError?) in - if let res = result { - do { - /* TODO: Don't use a hack (there is no reason to pass to JSON): */ - let jsonStr = try JSONEncoder().encode(res) - let decoded = try JSONDecoder().decode(T.Response.self, from: jsonStr) - completionHandler(decoded, err) - } catch { - completionHandler(nil, WalletBackend.parseResponseError()) - } - } else { - completionHandler(nil, err) - } - } - } - - static func serializeRequestError() -> WalletBackendResponseError { - return WalletBackendResponseError(talerErrorCode: -1, talerErrorHint: "Could not serialize request.", message: "") - } - - static func parseResponseError() -> WalletBackendResponseError { - return WalletBackendResponseError(talerErrorCode: -2, talerErrorHint: "Could not parse response.", message: "") - } - - static func parseFailureError() -> WalletBackendResponseError { - return WalletBackendResponseError(talerErrorCode: -3, talerErrorHint: "Could not parse error detail.", message: "") - } -} diff --git a/TalerWallet1/Backend/Transaction.swift b/TalerWallet1/Backend/Transaction.swift @@ -0,0 +1,314 @@ +/* + * 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 +import AnyCodable +import taler_swift +import SymLog + +enum TransactionTypeError: Error { + case unknownTypeError +} + +/// Different types of transactions. +enum TransactionType: Codable { + case withdrawal + case payment + case refund + case tip + case refresh + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + let str = try value.decode(String.self) + let codingNames = [ + "TransactionWithdrawal" : TransactionType.withdrawal, + "TransactionPayment" : TransactionType.payment, + "TransactionRefund" : TransactionType.refund, + "TransactionTip" : TransactionType.tip, + "TransactionRefresh" : TransactionType.refresh + ] + if let type = codingNames[str] { + self = type + } else { + throw TransactionTypeError.unknownTypeError + } + } + + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .withdrawal: + try value.encode("TransactionWithdrawal") + case .payment: + try value.encode("TransactionPayment") + case .refund: + try value.encode("TransactionRefund") + case .tip: + try value.encode("TransactionTip") + case .refresh: + try value.encode("TransactionRefresh") + } + } +} + +enum TransactionDecodingError: Error { + case invalidStringValue +} + +/// Details for a manual withdrawal. +struct ManualWithdrawalDetails: Codable { + /// The payto URIs that the exchange supports. + var exchangePaytoUris: [String] + + /// The public key of the newly created reserve. + var reservePub: String +} + +/// Details for a bank-integrated withdrawal. +struct BankIntegratedWithdrawalDetails: Codable { + /// Whether the bank has confirmed the withdrawal. + var confirmed: Bool + + /// The public key of the newly created reserve. + var reservePub: String? + + /// URL for user-initiated confirmation + var bankConfirmationUrl: String? +} + +/// A withdrawal transaction. +struct TransactionWithdrawal: Decodable { + enum WithdrawalDetails { + case manual(ManualWithdrawalDetails) + case bankIntegrated(BankIntegratedWithdrawalDetails) + } + + /// The exchange that was withdrawn from. + var exchangeBaseUrl: String + + /// The amount of the withdrawal, including fees. + var amountRaw: Amount + + /// The amount that will be added to the withdrawer's account. + var amountEffective: Amount + + /// The details of the withdrawal. + var withdrawalDetails: WithdrawalDetails + + init(from decoder: Decoder) throws { + enum CodingKeys: String, CodingKey { + case exchangeBaseUrl + case amountRaw + case amountEffective + case withdrawalDetails + case type + case exchangePaytoUris + case reservePub + case confirmed + case bankConfirmationUrl + } + + let value = try decoder.container(keyedBy: CodingKeys.self) + self.exchangeBaseUrl = try value.decode(String.self, forKey: .exchangeBaseUrl) + self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) + self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) + + let detail = try value.nestedContainer(keyedBy: CodingKeys.self, forKey: .withdrawalDetails) + let detailType = try detail.decode(String.self, forKey: .type) + if detailType == "manual-transfer" { + let paytoUris = try detail.decode([String].self, forKey: .exchangePaytoUris) + let reservePub = try detail.decode(String.self, forKey: .reservePub) + let manual = ManualWithdrawalDetails(exchangePaytoUris: paytoUris, reservePub: reservePub) + self.withdrawalDetails = .manual(manual) + } else if detailType == "taler-bank-integration-api" { + let confirmed = try detail.decode(Bool.self, forKey: .confirmed) + var bankConfirmationUrl: String? = nil + if detail.contains(.bankConfirmationUrl) { + bankConfirmationUrl = try detail.decode(String.self, forKey: .bankConfirmationUrl) + } + var reservePub : String? = nil + if detail.contains(.reservePub) { + reservePub = try detail.decode(String.self, forKey: .reservePub) + } + let bankDetails = BankIntegratedWithdrawalDetails(confirmed: confirmed, reservePub: reservePub, + bankConfirmationUrl: bankConfirmationUrl) + self.withdrawalDetails = .bankIntegrated(bankDetails) + } else { + throw TransactionDecodingError.invalidStringValue + } + } +} +#if DEBUG +extension TransactionWithdrawal { // for PreViews + init(url: String) { + self.exchangeBaseUrl = url + self.amountRaw = try! Amount(fromString: "Taler:5") + self.amountEffective = try! Amount(fromString: "Taler:4.8") + let bankDetails = BankIntegratedWithdrawalDetails(confirmed: true, reservePub: nil, + bankConfirmationUrl: nil) + self.withdrawalDetails = .bankIntegrated(bankDetails) + } +} +#endif + +/// A payment transaction. +struct TransactionPayment: Codable { + /// Additional information about the payment. + // TODO + + /// An identifier for the payment. + var proposalId: String + + /// The current status of the payment. + // TODO + + /// The amount that must be paid. + var amountRaw: Amount + + /// The amount that was paid. + var amountEffective: Amount +} + +/// A refund transaction. +struct TransactionRefund: Codable { + /// Identifier for the refund. + var refundedTransactionId: String + + /// Additional information about the refund + // TODO + + /// The amount that couldn't be applied because refund permissions expired. + var amountInvalid: Amount + + /// The amount refunded by the merchant. + var amountRaw: Amount + + /// The amount paid to the wallet after fees. + var amountEffective: Amount +} + +/// A tip transaction. +struct TransactionTip: Codable { + /// The current status of the tip. + // TODO + + /// The exchange that the tip will be withdrawn from + var exchangeBaseUrl: String + + /// More information about the merchant sending the tip. + // TODO + + /// The raw amount of the tip without fees. + var amountRaw: Amount + + /// The amount added to the recipient's wallet. + var amountEffective: Amount +} + +/// A refresh transaction. +struct TransactionRefresh: Codable { + /// The exchange that the coins are refreshed with. + var exchangeBaseUrl: String + + /// The raw amount to refresh. + var amountRaw: Amount + + /// The amount to be paid as fees for the refresh. + var amountEffective: Amount +} + +/// A wallet transaction. +struct Transaction: Decodable, Hashable { +// private let symLog = SymLogC(0) + + var type: String + var amountRaw: Amount + var amountEffective: Amount + var transactionId: String + var timestamp: Timestamp + var extendedStatus: String + var pending: Bool + var frozen: Bool + + var error: AnyCodable? + var exchangeBaseUrl: String? + + + +// enum TransactionDetail { +// case withdrawal(TransactionWithdrawal) +// } + +// var detail: TransactionDetail + +// init(from decoder: Decoder) throws { +// enum CodingKeys: String, CodingKey { +// case transactionId +// case timestamp +// case pending +// case error +// case amountRaw +// case amountEffective +// case type +// } +// +// let value = try decoder.container(keyedBy: CodingKeys.self) +// self.transactionId = try value.decode(String.self, forKey: .transactionId) +// self.timestamp = try value.decode(Timestamp.self, forKey: .timestamp) +// self.pending = try value.decode(Bool.self, forKey: .pending) +// if value.contains(.error) { +// self.error = try value.decode(AnyCodable.self, forKey: .error) +// } +// self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) +// self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) +// +// let type = try value.decode(String.self, forKey: .type) +// if type == "withdrawal" { +// let withdrawDetail = try TransactionWithdrawal.init(from: decoder) +// self.detail = .withdrawal(withdrawDetail) +// } else { +// throw TransactionDecodingError.invalidStringValue +// } +// symLog.log("\(self)") +// } + + static func == (lhs: Transaction, rhs: Transaction) -> Bool { + return lhs.transactionId == rhs.transactionId + } + + func hash(into hasher: inout Hasher) { + transactionId.hash(into: &hasher) + } +} + +#if DEBUG +extension Transaction { // for PreViews + init(id: String, time: Timestamp) { + self.type = "withdrawal" + self.amountRaw = try! Amount(fromString: "Taler:5") + self.amountEffective = try! Amount(fromString: "Taler:4.8") + self.transactionId = id + self.timestamp = time + self.extendedStatus = "done" + self.pending = false + self.frozen = false + self.error = nil + self.exchangeBaseUrl = "Exchange.Demo.Taler.net" +// let withdrawDetail = TransactionWithdrawal(url: "Exchange.Demo.Taler.net") +// self.detail = .withdrawal(withdrawDetail) + } +} +#endif diff --git a/TalerWallet1/Backend/WalletBackendError.swift b/TalerWallet1/Backend/WalletBackendError.swift @@ -0,0 +1,54 @@ +/* + * 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 + +/// Errors for `WalletBackend`. +enum WalletBackendError: Error { + /// An error that prevented the wallet from being initialized occurred. + case initializationError + case serializationError + case deserializationError + case walletCoreError +} + +/// Information supplied by the backend describing an error. +struct WalletBackendResponseError: Decodable { + /// Numeric error code defined defined in the GANA gnu-taler-error-codes registry. + var talerErrorCode: Int + + /// English description of the error code. + var talerErrorHint: String + + /// English diagnostic message that can give details for the instance of the error. + var message: String + + /// Error details, type depends on `talerErrorCode`. + var details: Data? +} + +extension WalletCore { + static func serializeRequestError() -> WalletBackendResponseError { + return WalletBackendResponseError(talerErrorCode: -1, talerErrorHint: "Could not serialize request.", message: "") + } + + static func parseResponseError() -> WalletBackendResponseError { + return WalletBackendResponseError(talerErrorCode: -2, talerErrorHint: "Could not parse response.", message: "") + } + + static func parseFailureError() -> WalletBackendResponseError { + return WalletBackendResponseError(talerErrorCode: -3, talerErrorHint: "Could not parse error detail.", message: "") + } +} diff --git a/TalerWallet1/Backend/WalletBackendRequest.swift b/TalerWallet1/Backend/WalletBackendRequest.swift @@ -0,0 +1,434 @@ +/* + * 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 +import AnyCodable +import taler_swift + +/// A request sent to the wallet backend. +struct WalletBackendRequest: Encodable { + /// The operation name of the request. + var operation: String + + /// The body of the request as JSON. + var args: AnyEncodable +} + +protocol WalletBackendFormattedRequest { + associatedtype Args: Encodable + associatedtype Response: Decodable + + func operation() -> String + func args() -> Args +} +// MARK: - + + +/// A billing or mailing location. +struct Location: Codable { + var country: String? + var country_subdivision: String? + var district: String? + var town: String? + var town_location: String? + var post_code: String? + var street: String? + var building_name: String? + var building_number: String? + var address_lines: [String]? +} + +/// Information identifying a merchant. +struct Merchant: Codable { + var name: String + var address: Location? + var jurisdiction: Location? +} + +/// A tax made on a payment. +struct Tax: Codable { + var name: String + var tax: Amount +} + +/// A product being purchased from a merchant. +struct Product: Codable { + var product_id: String? + var description: String + // description_i18n? + var quantity: Int + var unit: String + var price: Amount? + var image: String // URL to a product image + var taxes: [Tax]? + var delivery_date: Timestamp? +} + +/// Brief information about an order. +struct OrderShortInfo: Codable { + var orderId: String + var merchant: Merchant + var summary: String + // summary_i18n? + var products: [Product] + var fulfillmentUrl: String? + var fulfillmentMessage: String? + // fulfillmentMessage_i18n? +} + + +/// A request to delete a wallet transaction by ID. +struct WalletBackendDeleteTransactionRequest: WalletBackendFormattedRequest { + var transactionId: String + + struct Args: Encodable { + var transactionId: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "deleteTransaction" + } + + func args() -> Args { + return Args(transactionId: transactionId) + } +} + +/// A request to process a refund. +struct WalletBackendApplyRefundRequest: WalletBackendFormattedRequest { + var talerRefundUri: String + + struct Args: Encodable { + var talerRefundUri: String + } + + struct Response: Decodable { + var contractTermsHash: String + var amountEffectivePaid: Amount + var amountRefundGranted: Amount + var amountRefundGone: Amount + var pendingAtExchange: Bool + var info: OrderShortInfo + } + + func operation() -> String { + return "applyRefund" + } + + func args() -> Args { + return Args(talerRefundUri: talerRefundUri) + } +} + + +/// A request to force update an exchange. +struct WalletBackendForceUpdateRequest: WalletBackendFormattedRequest { + var exchangeBaseUrl: String + + struct Args: Encodable { + var exchangeBaseUrl: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "addRequest" + } + + func args() -> Args { + return Args(exchangeBaseUrl: exchangeBaseUrl) + } +} + + + + +/// A request to accept a bank-integrated withdrawl. +struct WalletBackendAcceptBankIntegratedWithdrawalRequest: WalletBackendFormattedRequest { + var talerWithdrawUri: String + var exchangeBaseUrl: String + + struct Args: Encodable { + var talerWithdrawUri: String + var exchangeBaseUrl: String + } + + struct Response: Decodable { + var bankConfirmationUrl: String? + } + + func operation() -> String { + return "acceptWithdrawal" + } + + func args() -> Args { + return Args(talerWithdrawUri: talerWithdrawUri, exchangeBaseUrl: exchangeBaseUrl) + } +} + +/// A request to accept a manual withdrawl. +struct WalletBackendAcceptManualWithdrawalRequest: WalletBackendFormattedRequest { + var exchangeBaseUrl: String + var amount: Amount + + struct Args: Encodable { + var exchangeBaseUrl: String + var amount: Amount + } + + struct Response: Decodable { + var exchangePaytoUris: [String] + } + + func operation() -> String { + return "acceptManualWithdrawal" + } + + func args() -> Args { + return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount) + } +} + +/// A request to deposit funds. +struct WalletBackendCreateDepositGroupRequest: WalletBackendFormattedRequest { + var depositePayToUri: String + var amount: Amount + + struct Args: Encodable { + var depositPayToUri: String + var amount: Amount + } + + struct Response: Decodable { + var depositGroupId: String + } + + func operation() -> String { + return "createDepositGroup" + } + + func args() -> Args { + return Args(depositPayToUri: depositePayToUri, amount: amount) + } +} + +/// A request to get information about a payment request. +struct WalletBackendPreparePayRequest: WalletBackendFormattedRequest { + var talerPayUri: String + + struct Args: Encodable { + var talerPayUri: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "preparePay" + } + + func args() -> Args { + return Args(talerPayUri: talerPayUri) + } +} + +/// A request to confirm a payment. +struct WalletBackendConfirmPayRequest: WalletBackendFormattedRequest { + var proposalId: String + + struct Args: Encodable { + var proposalId: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "confirmPay" + } + + func args() -> Args { + return Args(proposalId: proposalId) + } +} + +/// A request to prepare a tip. +struct WalletBackendPrepareTipRequest: WalletBackendFormattedRequest { + var talerTipUri: String + + struct Args: Encodable { + var talerTipUri: String + } + + struct Response: Decodable { + var walletTipId: String + var accepted: Bool + var tipAmountRaw: Amount + var tipAmountEffective: Amount + var exchangeBaseUrl: String + var expirationTimestamp: Timestamp + } + + func operation() -> String { + return "prepareTip" + } + + func args() -> Args { + return Args(talerTipUri: talerTipUri) + } +} + +/// A request to accept a tip. +struct WalletBackendAcceptTipRequest: WalletBackendFormattedRequest { + var walletTipId: String + + struct Args: Encodable { + var walletTipId: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "acceptTip" + } + + func args() -> Args { + return Args(walletTipId: walletTipId) + } +} + +/// A request to abort a failed payment. +struct WalletBackendAbortFailedPaymentRequest: WalletBackendFormattedRequest { + var proposalId: String + + struct Args: Encodable { + var proposalId: String + } + + struct Response: Decodable {} + + func operation() -> String { + return "abortFailedPayWithRefund" + } + + func args() -> Args { + return Args(proposalId: proposalId) + } +} + + +struct IntegrationTestArgs: Codable { + var exchangeBaseUrl: String + var bankBaseUrl: String + var merchantBaseUrl: String + var merchantApiKey: String + var amountToWithdraw: String + var amountToSpend: String +} + +/// A request to run a basic integration test. +struct WalletBackendRunIntegrationTestRequest: WalletBackendFormattedRequest { + var integrationTestArgs: IntegrationTestArgs + + typealias Args = IntegrationTestArgs + + struct Response: Decodable {} + + func operation() -> String { + return "runIntegrationTest" + } + + func args() -> Args { + return integrationTestArgs + } +} + +struct TestPayArgs: Codable { + var merchantBaseUrl: String + var merchantApiKey: String + var amount: String + var summary: String +} + +/// A request to make a test payment. +struct WalletBackendTestPayRequest: WalletBackendFormattedRequest { + var testPayArgs: TestPayArgs + + typealias Args = TestPayArgs + + struct Response: Decodable {} + + func operation() -> String { + return "testPay" + } + + func args() -> Args { + return testPayArgs + } +} + +struct Coin: Codable { + var denom_pub: String + var denom_pub_hash: String + var denom_value: String + var coin_pub: String + var exchange_base_url: String + var remaining_value: String + var refresh_parent_coin_pub: String + var withdrawal_reserve_pub: String + var coin_suspended: Bool +} + +/// A request to dump all coins to JSON. +struct WalletBackendDumpCoinsRequest: WalletBackendFormattedRequest { + struct Args: Encodable { + + } + + struct Response: Decodable { + var coins: [Coin] + } + + func operation() -> String { + return "dumpCoins" + } + + func args() -> Args { + return Args() + } +} + +/// A request to suspend or unsuspend a coin. +struct WalletBackendSuspendCoinRequest: WalletBackendFormattedRequest { + var coinPub: String + var suspended: Bool + + struct Args: Encodable { + var coinPub: String + var suspended: Bool + } + + struct Response: Decodable {} + + func operation() -> String { + return "setCoinSuspended" + } + + func args() -> Args { + return Args(coinPub: coinPub, suspended: suspended) + } +} + + diff --git a/TalerWallet1/Backend/WalletCore.swift b/TalerWallet1/Backend/WalletCore.swift @@ -0,0 +1,262 @@ +/* + * 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 // FOUNDATION has no AppStorage +import AnyCodable +import FTalerWalletcore +import SymLog + +/// Delegate for the wallet backend. +protocol WalletBackendDelegate { + /// Called when the backend interface receives a message it does not know how to handle. + func walletBackendReceivedUnknownMessage(_ walletCore: WalletCore, message: String) +} + +/// An interface to the wallet backend. +class WalletCore: QuickjsMessageHandler { + private let symLog = SymLogC() + + private var quickjs: Quickjs + private var requestsMade: UInt // counter for array of completion closures + private var completions: [UInt : (UInt, Data?, WalletBackendResponseError?) -> Void] = [:] + var delegate: WalletBackendDelegate? + + var versionInfo: VersionInfo? // shown in SettingsView + var developDelay: Bool? // if set in SettingsView will delay wallet-core after each action + + private struct FullRequest: Encodable { + let operation: String + let id: UInt + let args: AnyEncodable + } + + private struct FullResponse: Decodable { + let type: String + let operation: String + let id: UInt + let result: AnyCodable + } + + struct FullError: Decodable { + let type: String + let operation: String + let id: UInt + let error: WalletBackendResponseError + } + + var lastError: FullError? + + deinit { + symLog.log() + // TODO: send shutdown message to talerWalletInstance +// quickjs.waitStopped() + } + + init() throws { + requestsMade = 0 + quickjs = Quickjs() + quickjs.messageHandler = self + } +} +// MARK: - completionHandler functions +extension WalletCore { + + private func handleResponse(dict responseDict: [String : Any], data messageData: Data, isError: Bool = false) throws { + guard let id = responseDict["id"] as? UInt else { throw WalletBackendError.deserializationError } + guard let completion = completions[id] else { throw WalletBackendError.deserializationError } + completions[id] = nil + if isError { + do { + let decoded = try JSONDecoder().decode(FullError.self, from: messageData) + symLog.log(decoded) + completion(id, nil, decoded.error) + } catch { + symLog.log(responseDict) // TODO: error + completion(id, nil, WalletCore.parseFailureError()) + } + } else { + do { // pass response.result + let decoded = try JSONDecoder().decode(FullResponse.self, from: messageData) + symLog.log(decoded) + let jsonData = try JSONEncoder().encode(decoded.result) + completion(id, jsonData, nil) + } catch { + symLog.log(responseDict) // TODO: error + completion(id, nil, WalletCore.parseResponseError()) + } + } + } + + private func handleNotification(dict responseDict: [String : Any]) throws { + do { + guard let payload = (responseDict["payload"] as? [String : Any]) else { throw WalletBackendError.deserializationError } + guard let type = (payload["type"] as? String) else { throw WalletBackendError.deserializationError } + switch type { + case "pending-operation-processed": + guard let id = (payload["id"] as? String) else { throw WalletBackendError.deserializationError } + if id.hasPrefix("exchange-update:") { + // TODO: handle exchange-update + } else { + symLog.log(id) + // TODO: handle other pending-operation-processed + } + case "coin-withdrawn", "withdraw-group-finished", "pay-operation-success": + symLog.log(payload) + Task { + do { + try await Controller.shared.balancesModel.fetchBalances() + } catch { + // TODO: show error + symLog.log(error.localizedDescription) + } + } + break + case "proposal-accepted": + symLog.log(payload) + break + case "proposal-downloaded": + symLog.log(payload) + break + case "waiting-for-retry": + // Bla Bla Bla + break + case "exchange-added": + symLog.log(payload) + break + case "refresh-started": + symLog.log(payload) + break + case "refresh-melted": + symLog.log(payload) + break + case "refresh-revealed": + symLog.log(payload) + break + case "reserve-registered-with-bank": + symLog.log(payload) + break + default: + symLog.log(payload) + break + } + } catch let error { + symLog.log("Error \(error) parsing notification: \(responseDict)") // TODO: .error + // TODO: if DevMode then should log into file for user + } + } + + /// here not only responses, but also notifications from wallet-core will be received + func handleMessage(message: String) { + do { + var asyncDelay = 0 + if let delay = developDelay { + if delay { + asyncDelay = 2 + } + } + if asyncDelay > 0 { + symLog.log(message) + symLog.log("...going to sleep for \(asyncDelay) seconds...") + sleep(UInt32(asyncDelay)) + symLog.log("waking up again after \(asyncDelay) seconds, will deliver message") + } + guard let messageData = message.data(using: .utf8) else { throw WalletBackendError.deserializationError } + let jsonDict = try JSONSerialization.jsonObject(with: messageData, options: .allowFragments) as? [String : Any] + guard let responseDict = jsonDict else { throw WalletBackendError.deserializationError } + guard let responseType = (responseDict["type"] as? String) else { throw WalletBackendError.deserializationError } + switch responseType { + case "error": + try handleResponse(dict: responseDict, data: messageData, isError: true) + case "response": + try handleResponse(dict: responseDict, data: messageData) + case "notification": + try handleNotification(dict: responseDict) + case "tunnelHttp": // TODO: Handle tunnelHttp + break + default: + symLog.log("Unknown response type: \(responseDict)") // TODO: .error + throw WalletBackendError.deserializationError + } + } catch { + delegate?.walletBackendReceivedUnknownMessage(self, message: message) + } + } + + private func sendRequest(request: WalletBackendRequest, completionHandler: @escaping (UInt, Data?, WalletBackendResponseError?) -> Void) { + // Encode the request and send it to the backend. + let id = requestsMade + do { + let full = FullRequest(operation: request.operation, id: id, args: request.args) +// symLog.log(full) + let encoded = try JSONEncoder().encode(full) + guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError } + completions[id] = completionHandler + requestsMade += 1 + symLog.log(jsonString) + quickjs.sendMessage(message: jsonString) + } catch { + completionHandler(id, nil, WalletCore.serializeRequestError()); + } + } + + /// call this to send requests to wallet-core + func sendFormattedRequest<T: WalletBackendFormattedRequest> + (request: T, completionHandler: @escaping (T.Response?, WalletBackendResponseError?) -> Void) + { + let reqData = WalletBackendRequest(operation: request.operation(), + args: AnyEncodable(request.args())) + sendRequest(request: reqData) { (id: UInt, result: Data?, err: WalletBackendResponseError?) in + guard let json = result else { completionHandler(nil, err); return } + do { + let decoded = try JSONDecoder().decode(T.Response.self, from: json) + completionHandler(decoded, err) + } catch { + completionHandler(nil, WalletCore.parseResponseError()) + } + } + } +} +// MARK: - async / await function +extension WalletCore { + /// send async requests to wallet-core + func sendFormattedRequest<T: WalletBackendFormattedRequest> (request: T) async throws -> (T.Response, UInt) { + let reqData = WalletBackendRequest(operation: request.operation(), + args: AnyEncodable(request.args())) + return try await withCheckedThrowingContinuation { continuation in + sendRequest(request: reqData) { id, result, error in + if let json = result { + do { + let decoded = try JSONDecoder().decode(T.Response.self, from: json) + continuation.resume(returning: (decoded, id)) + } catch { + if let jsonString = String(data: json, encoding: .utf8) { + self.symLog.log(jsonString) // TODO: .error + } else { + self.symLog.log(json) // TODO: .error + } + continuation.resume(throwing: error) + } + } else { + if let error = error { + self.lastError = FullError(type: "error", operation: request.operation(), id: id, error: error) + } else { + self.lastError = nil + } + continuation.resume(throwing: WalletBackendError.walletCoreError) + } + } + } + } +} diff --git a/TalerWallet1/Controllers/Controller.swift b/TalerWallet1/Controllers/Controller.swift @@ -0,0 +1,128 @@ +/* + * 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 +import SymLog + +enum BackendState { + case none + case instantiated + case initing + case ready + case error +} + +enum UrlCommand { + case unknown + case withdraw + case pay +} + +class Controller: ObservableObject { + public static var shared = Controller() + private let symLog = SymLogC() + + @Published var backendState: BackendState = .none + + var walletCore: WalletCore + var exchangeModel: ExchangeModel + var balancesModel: BalancesModel + var transactionsModel: TransactionsModel + var pendingModel: PendingModel + var withdrawURIModel: WithdrawURIModel + var paymentURIModel: PaymentURIModel +// @Published var withdrawTestModel: WithdrawTestModel + + var messageForSheet: String? = nil + + init() { + symLog.log("init wallet-core") + walletCore = try! WalletCore() // will (and should) crash on failure + symLog.log("wallet-core done") + exchangeModel = ExchangeModel(walletCore: walletCore) + balancesModel = BalancesModel(walletCore: walletCore) + transactionsModel = TransactionsModel(walletCore: walletCore) + pendingModel = PendingModel(walletCore: walletCore) + withdrawURIModel = WithdrawURIModel(walletCore: walletCore) + paymentURIModel = PaymentURIModel(walletCore: walletCore) +// withdrawTestModel = WithdrawTestModel(walletCore: walletCore) + symLog.log("models inited") + backendState = .instantiated + } + + @MainActor func initWalletCore() async throws { + if backendState == .instantiated { + backendState = .initing + do { + let walletInitModel = WalletInitModel(walletCore: walletCore) + let versionInfo = try await walletInitModel.initWallet() + walletCore.versionInfo = versionInfo + backendState = .ready // dismiss the launch animation + } catch { + backendState = .error + throw error + } + } else { + symLog.log("Yikes\(logSymbol(-1)) wallet-core already initialized") // TODO: .warning + } + } +} + +// MARK: - +extension Controller { + func openURL(_ url:URL) -> UrlCommand { + guard let scheme = url.scheme else {return UrlCommand.unknown} + var uncrypted = false + switch scheme { + case "taler+http": + uncrypted = true + fallthrough + case "taler": + return talerScheme(url, uncrypted) + case "payto": + messageForSheet = url.absoluteString + return paytoScheme(url) + default: + symLog.log("unknown scheme: <\(scheme)>") // should never happen + } + return UrlCommand.unknown + } +} +// MARK: - +extension Controller { + func paytoScheme(_ url:URL) -> UrlCommand { + let logItem = "scheme payto:// is not yet implemented" + // TODO: write logItem to somewhere in Debug section of SettingsView + symLog.log(logItem) // TODO: symLog.error(logItem) + return UrlCommand.unknown + } + + func talerScheme(_ url:URL,_ uncrypted: Bool = false) -> UrlCommand { + guard let command = url.host else {return UrlCommand.unknown} + if uncrypted { + print("uncrypted") + // TODO: uncrypted + } + switch command { + case "withdraw": + return UrlCommand.withdraw + case "pay": + return UrlCommand.pay + default: + symLog.log("unknown command taler://\(command)") + } + return UrlCommand.unknown + } +} diff --git a/TalerWallet1/Controllers/TalerWallet1App.swift b/TalerWallet1/Controllers/TalerWallet1App.swift @@ -0,0 +1,81 @@ +/* + * This file is part of GNU Taler + * (C) 2021 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 BackgroundTasks +import SwiftUI +import SymLog +#if DEBUG +let schemes: Set = ["taler", "payto", "taler+http"] +#else +let schemes: Set = ["taler", "payto"] +#endif + +@main +struct TalerWallet1App: App { + private let symLog = SymLogV(0) + @Environment(\.scenePhase) private var phase + + // our main controller + @StateObject private var controller = Controller.shared + + func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: "net.taler.refresh") + request.earliestBeginDate = .now.addingTimeInterval(24 * 3600) + try? BGTaskScheduler.shared.submit(request) + } + + var body: some Scene { + WindowGroup { + symLog { ContentView() + .environmentObject(controller) + .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) + .task { + symLog.log("task -> initWalletCore") + try? await controller.initWalletCore() + symLog.log("task done") + } + } + } + .onChange(of: phase) { newPhase in + switch newPhase { + case .background: scheduleAppRefresh() + default: break + } + } +// if #available(iOS 16.0, *) { +// .backgroundTask(.appRefresh("net.taler.refresh")) { +// symLog.log("backgroundTask running") +//#if 0 +// let request = URLRequest(url: URL(string: "your_backend")!) +// guard let data = try? await URLSession.shared.data(for: request).0 else { +// return +// } +// +// let decoder = JSONDecoder() +// guard let products = try? decoder.decode([Product].self, from: data) else { +// return +// } +// +// if !products.isEmpty && !Task.isCancelled { +// await notifyUser(for: products) +// } +//#endif +// } +// } else { +// // Fallback on earlier versions +// } + + } +} diff --git a/TalerWallet1/Helper/TalerDater.swift b/TalerWallet1/Helper/TalerDater.swift @@ -0,0 +1,102 @@ +/* + * 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 +import taler_swift + +public class TalerDater: DateFormatter { + public static var shared = TalerDater() + + static func relativeDate(from: TimeInterval) -> String? { + if from > 0 { // transactions should always be in the past + let minute = from / 60.0 // from is in seconds + if minute < 1 { return "Right now" } + if minute < 2 { return "1 minute ago" } + if minute < 55 { return "\(Int(minute)) minutes ago" } + if minute < 60 { return "About an hour ago" } + if minute < 80 { return "1 hour ago" } + if minute < 105 { return "About 1½ hours ago" } + if minute < 125 { return "About 2 hours ago" } + let hour = minute / 60.0 + let calendar = Calendar.current + let now = Date.now + let currHour = Double(calendar.component(.hour, from: now)) + let currMin = Double(calendar.component(.minute, from: now)) + let currTime = currHour + currMin/60 + if hour < currTime { return "\(Int(hour)) hours ago" } + if hour < currTime + 24 { return "Yesterday" } + let day = (hour - currTime) / 24.0 + if day < 7 { return "\(Int(day+1)) days ago" } + if day < 14 { return "More than a week ago" } + // will fall thru... + return nil + } else { // Yikes! transaction date is in the future + return nil + } + } + + /// produces a random date string between `now` and m+h+d (edit values after 60x) + public static func randomDateStr() -> String { + let m = 60*15 + let h = 60*60*9 + let d = 24*60*60*22 + let t = m+h+d + let randomTime = Int.random(in:1...t) + if let randomDateStr = relativeDate(from: Double(randomTime)) { + return randomDateStr + } else { // t is too large for a relative date + // return absolute date with random locale + let localeStr = (randomTime&1 == 1) ? "de_DE" : "en_US" + shared.locale = NSLocale(localeIdentifier: localeStr) as Locale + let randomDate = Date(timeIntervalSinceNow: Double(-t)) + return shared.string(from: randomDate) + } + } + + /// converts a timestamp into a formatted date string + public static func dateString(from: Timestamp, relative: Bool = false) -> String { + do { + let milliseconds = try from.milliseconds() + let date = Date(milliseconds: milliseconds) + if relative { + let now = Date.now + let timeInterval = now.timeIntervalSince(date) + if let relativeDate = relativeDate(from: timeInterval) { + return relativeDate + } + } + return shared.string(from: date) + } catch { + return "Never" + } + } + + public static func dateString() -> String { + return shared.string(from: Date()) + } + + private override init() { + super.init() + self.setLocalizedDateFormatFromTemplate("EEEdMMM") // abbreviated day of week + self.dateStyle = .medium + self.timeStyle = .short +// self.timeZone = TimeZone(abbreviation: "UTC") // UTC prints GMT +// self.dateFormat = "z yyyy-MM-dd HH:mm" // "GMT 2022-11-09 18:00" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/TalerWallet1/Helper/TalerStrings.swift b/TalerWallet1/Helper/TalerStrings.swift @@ -0,0 +1,28 @@ +/* + * 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 + +extension StringProtocol { + + func trimURL() -> String { + if let url = URL(string: String(self)) { + if let host = url.host { + return host + } + } + return String(self) + } +} diff --git a/TalerWallet1/Helper/View+dismissTop.swift b/TalerWallet1/Helper/View+dismissTop.swift @@ -0,0 +1,41 @@ +/* + * 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 + +/// This is just a workaround for a SwiftUI bug +/// A presented sheet (SwiftUI view) doesn't always close when calling "dismiss()" provided by @Environment(\.dismiss), +/// so we are walking the view stack to find the top presentedViewController (UIKit) and dismiss it. +extension View { + public func dismissTop(animated: Bool = true) { + let windows = UIApplication.shared.connectedScenes.compactMap { + ($0 as? UIWindowScene)?.keyWindow // TODO: iPad might have more than 1 window + } + if var topController = windows.first?.rootViewController { + var gotPresented = false + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + gotPresented = true + } + if gotPresented { + topController.dismiss(animated: animated) + } else { + print("Yikes❗️ Trying to dismiss the rootViewController!") + } + } else { + print("Yikes❗️ There is no window/rootViewController!") + } + } +} diff --git a/TalerWallet1/Model/ExchangeTestModel.swift b/TalerWallet1/Model/ExchangeTestModel.swift @@ -0,0 +1,138 @@ +/* + * 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 +import taler_swift +import SymLog + +fileprivate let EXCHANGEBASEURL = "https://exchange.demo.taler.net/" +fileprivate let BANKBASEURL = "https://bank.demo.taler.net/" +fileprivate let BANKACCESSAPIBASEURL = "https://bank.demo.taler.net/demobanks/default/access-api/" +fileprivate let MERCHANTBASEURL = "https://backend.demo.taler.net/" +fileprivate let MERCHANTAUTHTOKEN = "secret-token:sandbox" + +// MARK: - +class ExchangeTestModel: ObservableObject { + private let symLog = SymLogC(0) + + var walletCore: WalletCore + + @Published var loading: Bool = false + + init(walletCore: WalletCore) { + self.walletCore = walletCore + } +} +// MARK: - +extension ExchangeTestModel { + func loadTestKudos() { + loading = true + + let amount = Amount(currency: "KUDOS", integer: 11, fraction: 0) + let req = WalletBackendWithdrawTestBalance(amount: amount, bankBaseUrl: BANKBASEURL, + exchangeBaseUrl: EXCHANGEBASEURL, bankAccessApiBaseUrl: BANKACCESSAPIBASEURL) + symLog.log("sending: \(req)") + walletCore.sendFormattedRequest(request: req) { response, err in + DispatchQueue.main.async { + self.loading = false + if let res = response { + // TODO: ? + self.symLog.log("received: \(res)") + } else { + // TODO: Handle error + } + } + } + } + + func runIntegrationTest() { + loading = true + + let amountW = Amount(currency: "KUDOS", integer: 3, fraction: 0) + let amountS = Amount(currency: "KUDOS", integer: 1, fraction: 0) + let req = WalletBackendRunIntegration(amountToWithdraw: amountW, + amountToSpend: amountS, + bankBaseUrl: BANKACCESSAPIBASEURL, + exchangeBaseUrl: EXCHANGEBASEURL, + merchantBaseUrl: MERCHANTBASEURL, + merchantAuthToken: MERCHANTAUTHTOKEN + ) + symLog.log("sending: \(req)") + walletCore.sendFormattedRequest(request: req) { response, err in + DispatchQueue.main.async { + self.loading = false + if let res = response { + // TODO: ? + self.symLog.log("received: \(res)") + } else { + // TODO: Handle error + } + } + } + } +} + +/// A request to add a test balance to the wallet. +fileprivate struct WalletBackendWithdrawTestBalance: WalletBackendFormattedRequest { + typealias Response = String + func operation() -> String { return "withdrawTestBalance" } + func args() -> Args { + return Args(amount: amount, bankBaseUrl: bankBaseUrl, + exchangeBaseUrl: exchangeBaseUrl, bankAccessApiBaseUrl: bankAccessApiBaseUrl) + } + + var amount: Amount + var bankBaseUrl: String + var exchangeBaseUrl: String + var bankAccessApiBaseUrl: String + + struct Args: Encodable { + var amount: Amount + var bankBaseUrl: String + var exchangeBaseUrl: String + var bankAccessApiBaseUrl: String + } +} + +/// A request to add a test balance to the wallet. +fileprivate struct WalletBackendRunIntegration: WalletBackendFormattedRequest { + typealias Response = String + func operation() -> String { return "runIntegrationTest" } + func args() -> Args { + return Args(amountToWithdraw: amountToWithdraw, + amountToSpend: amountToSpend, + bankBaseUrl: bankBaseUrl, + exchangeBaseUrl: exchangeBaseUrl, + merchantBaseUrl: merchantBaseUrl, + merchantAuthToken: merchantAuthToken + ) + } + + var amountToWithdraw: Amount + var amountToSpend: Amount + var bankBaseUrl: String + var exchangeBaseUrl: String + var merchantBaseUrl: String + var merchantAuthToken: String + + struct Args: Encodable { + var amountToWithdraw: Amount + var amountToSpend: Amount + var bankBaseUrl: String + var exchangeBaseUrl: String + var merchantBaseUrl: String + var merchantAuthToken: String + } +} diff --git a/TalerWallet1/Model/WalletInitModel.swift b/TalerWallet1/Model/WalletInitModel.swift @@ -0,0 +1,86 @@ +/* + * 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 +import SymLog + +let DATABASE = "talerwalletdb-v30" + +class WalletInitModel: WalletModel { + +} +// MARK: - +/// A request to initialize Wallet-core +fileprivate struct WalletBackendInitRequest: WalletBackendFormattedRequest { + func operation() -> String { return "init" } + func args() -> Args { + return Args(persistentStoragePath: persistentStoragePath, + cryptoWorkerType: "sync") + } + + struct Args: Encodable { + var persistentStoragePath: String + var cryptoWorkerType: String? + } + + var persistentStoragePath: String + + struct Response: Decodable { // versioninfo + var versionInfo: VersionInfo + enum CodingKeys: String, CodingKey { + case versionInfo = "versionInfo" + } + } +} +// MARK: - +/// The info returned from Wallet-core init +struct VersionInfo: Decodable { + var hash: String + var version: String + var exchange: String + var merchant: String + var bank: String + var devMode: Bool +} +// MARK: - +extension WalletInitModel { + /// initalize Wallet-Core. Will do networking + func initWallet() async throws -> VersionInfo? { + do { + let docPath = try docPath() + let request = WalletBackendInitRequest(persistentStoragePath: docPath) + symLog?.log("info: not main thread") + let response = try await sendRequest(request, 0) // no Delay + return response.versionInfo + } catch { + symLog?.log("error: \(error)") + throw error + } + } + + private func docPath () throws -> String { + let documentUrls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + if (documentUrls.count > 0) { + var storageDir = documentUrls[0] + storageDir.appendPathComponent(DATABASE, isDirectory: false) + storageDir.appendPathExtension("json") + return storageDir.path + } else { // should never happen + symLog?.log("Yikes! documentURLs empty") // TODO: symLog.error + throw WalletBackendError.initializationError + } + } +} + diff --git a/TalerWallet1/Model/WalletModel.swift b/TalerWallet1/Model/WalletModel.swift @@ -0,0 +1,55 @@ +/* + * 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 +import SymLog + +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +/// The "virtual" base class for all models +class WalletModel: ObservableObject { + static func className() -> String {"\(self)"} + var symLog: SymLogC? + var walletCore: WalletCore + + @Published var loading: Bool = false // update view + + init(walletCore: WalletCore) { + self.symLog = SymLogC(funcName: Self.className()) + self.walletCore = walletCore + } + + @MainActor func sendRequest<T: WalletBackendFormattedRequest> (_ request: T, _ delay: UInt = 0) + async throws -> T.Response { + loading = true // enter progressView + do { + symLog?.log("sending: \(request)") + let (response, id) = try await walletCore.sendFormattedRequest(request: request) + let asyncDelay: UInt = delay > 0 ? delay : UInt(ASYNCDELAY) + if asyncDelay > 0 { // test LoadingView, sleep some seconds + symLog?.log("received: (\(id)), going to sleep for \(asyncDelay) seconds...") + try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(asyncDelay)) + symLog?.log("waking up again after \(asyncDelay) seconds, will deliver \(response)") + } else { + symLog?.log("received: \(response)") + } + loading = false // exit progressView + return response + } catch { + throw error + } + } + +} diff --git a/TalerWallet1/Quickjs/quickjs.swift b/TalerWallet1/Quickjs/quickjs.swift @@ -0,0 +1,81 @@ +/* + * This file is part of GNU Taler + * (C) 2021 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 + +import FTalerWalletcore + +public protocol QuickjsMessageHandler: AnyObject { + func handleMessage(message: String) +} + +func notification_callback(userdata: Optional<UnsafeMutableRawPointer>, + payload: Optional<UnsafePointer<Int8>>) { + let native = Unmanaged<Quickjs>.fromOpaque(userdata!).takeUnretainedValue() + let string = String(cString: payload!) + native.internalOnNotify(payload: string) +} + +public class Quickjs { + var talerWalletInstance: OpaquePointer! + public weak var messageHandler: QuickjsMessageHandler? + + public init() { + self.talerWalletInstance = TALER_WALLET_create() + TALER_WALLET_set_message_handler(talerWalletInstance, + notification_callback, + Unmanaged.passUnretained(self).toOpaque()) + TALER_WALLET_run(talerWalletInstance); + } + + deinit { + // FIXME: TALER_WALLET_destroy +// TALER_WALLET_destroy(talerWalletInstance) + } + + + public func internalOnNotify(payload: String) { + if let handler = messageHandler { + handler.handleMessage(message: payload) + } + } + +// public func notifyNative() { +// __notifyNative(instance) +// } + +// public func evalNodeCode(source: String) { +// scheduleNodeThreadAsync { +// __makeCallbackNative(self.instance, source.cString(using: .utf8)) +// } +// } + + public func sendMessage(message: String) { + TALER_WALLET_send_request(talerWalletInstance, message) + } + + /// Note: This *must* be called before releasing the object, or else the thread will keep going. +// public func waitStopped() { +// scheduleNodeThreadSync { +// self.stopped = true +// } +// thread.cancel() +// } + +// public func putModuleCode(modName: String, code: String) { +// __putModuleCodeNative(self.instance, modName.cString(using: .utf8), +// code.cString(using: .utf8)) +// } +} diff --git a/TalerWallet1/Views/Balances/BalanceRow.swift b/TalerWallet1/Views/Balances/BalanceRow.swift @@ -0,0 +1,46 @@ +/* + * 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 BalanceRow: View { + let amount: Amount + let sendAction: () -> Void + let recvAction: () -> Void + var body: some View { + HStack { + Button("Send\nFunds", action: sendAction) + .lineLimit(nil) + .buttonStyle(.bordered) + .padding(.trailing) + Button("Receive\nFunds", action: recvAction) + .buttonStyle(.bordered) + Spacer() + VStack(alignment: .trailing) { + Text("Balance") + .font(.footnote) + Text("\(amount.valueStr)") + .font(.title) + } + } + } +} + +struct Balance_Previews: PreviewProvider { + static var previews: some View { + BalanceRow(amount: try! Amount(fromString: "Taler:0.1"), sendAction: {}, recvAction: {}) + } +} diff --git a/TalerWallet1/Views/Balances/BalancesModel.swift b/TalerWallet1/Views/Balances/BalancesModel.swift @@ -0,0 +1,73 @@ +/* + * 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 +import taler_swift +import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +class BalancesModel: WalletModel { + @Published var balances: [Balance]? // update view +} +// MARK: - +/// A request to get the balances held in the wallet. +fileprivate struct GetBalances: WalletBackendFormattedRequest { + func operation() -> String { return "getBalances" } + func args() -> Args { return Args() } + + struct Args: Encodable {} // no arguments needed + + struct Response: Decodable { // list of balances + var balances: [Balance] + } +} +// MARK: - +/// A currency balance +struct Balance: Decodable, Hashable { + var available: Amount + var pendingIncoming: Amount + var pendingOutgoing: Amount + var hasPendingTransactions: Bool + var requiresUserInput: Bool + + public static func == (lhs: Balance, rhs: Balance) -> Bool { + return lhs.available == rhs.available && + lhs.pendingIncoming == rhs.pendingIncoming && + lhs.pendingOutgoing == rhs.pendingOutgoing && + lhs.hasPendingTransactions == rhs.hasPendingTransactions && + lhs.requiresUserInput == rhs.requiresUserInput + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(available) + hasher.combine(pendingIncoming) + hasher.combine(pendingOutgoing) + hasher.combine(hasPendingTransactions) + hasher.combine(requiresUserInput) + } +} +// MARK: - +extension BalancesModel { + /// fetch Balances from Wallet-Core. No networking involved + @MainActor func fetchBalances() async throws { + do { + let request = GetBalances() + let response = try await sendRequest(request, ASYNCDELAY) + balances = response.balances // trigger view update in CurrenciesListView + } catch { + throw error + } + } +} diff --git a/TalerWallet1/Views/Balances/CurrenciesListView.swift b/TalerWallet1/Views/Balances/CurrenciesListView.swift @@ -0,0 +1,78 @@ +/* + * 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 SymLog + +struct CurrenciesListView: View { + private let symLog = SymLogV() + let navTitle = "GNU Taler Wallet" + + @ObservedObject var viewModel: BalancesModel + var hamburgerAction: () -> Void + + var body: some View { + let reloadAction = viewModel.fetchBalances + VStack { + if viewModel.balances == nil { + symLog { LoadingView(backButtonHidden: true) } + } else { + symLog { NavigationView { + Content(symLog: symLog, viewModel: viewModel, reloadAction: reloadAction) + .navigationBarItems(leading: HamburgerButton(action: hamburgerAction)) + .navigationTitle(navTitle) + } } + } + }.task { + symLog.log(".task") + do { + try await reloadAction() + } catch { + // TODO: show error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension CurrenciesListView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var viewModel: BalancesModel + @EnvironmentObject var controller : Controller + var reloadAction: () async throws -> () + + var body: some View { + if viewModel.balances!.isEmpty { // TODO: all spent? + WalletEmptyView() + .navigationBarTitleDisplayMode(.large) + } else { + List (viewModel.balances!, id: \.self) { balance in + NavigationLink { + TransactionsListView(viewModel: controller.transactionsModel) + } label: { + // TODO: sendAction, recvAction + CurrencyView(balance: balance, sendAction: {}, recvAction: {}) + } + } + .navigationBarTitleDisplayMode(.large) // .inline + .refreshable { + symLog?.log("refreshing") + try? await reloadAction() // TODO: catch error + } + } + } + } +} diff --git a/TalerWallet1/Views/Balances/CurrencyView.swift b/TalerWallet1/Views/Balances/CurrencyView.swift @@ -0,0 +1,58 @@ +/* + * 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 + +/// This view shows a currency +/// Header: Currency Name (e.g. Kudos) +/// [Send Funds] [Receive Funds] Balance +/// Pending Incoming +/// Pending Outgoing + +struct CurrencyView: View { + var balance:Balance + let sendAction: () -> Void + let recvAction: () -> Void + var body: some View { + VStack { + Text(balance.available.currencyStr) + .font(.title) + BalanceRow(amount: balance.available, sendAction: sendAction, recvAction: recvAction) + + let inAmount = balance.pendingIncoming + if !inAmount.isZero { + PendingRow(amount: inAmount, incoming: true, counterparty: "exchange.demo.taler.net") + } + let outAmount = balance.pendingOutgoing + if !outAmount.isZero { + PendingRow(amount: outAmount, incoming: false, counterparty: "merchant") + } + } +// .padding() + } +} + +struct CurrencyView_Previews: PreviewProvider { + static var balance = Balance(available: try! Amount(fromString: "Taler:0.1"), + pendingIncoming: try! Amount(fromString: "Taler:4.8"), + pendingOutgoing: try! Amount(fromString: "Taler:3.25"), + hasPendingTransactions: true, + requiresUserInput: false) + + static var previews: some View { + CurrencyView(balance: balance, sendAction: {}, recvAction: {}) + } +} diff --git a/TalerWallet1/Views/Balances/PendingRow.swift b/TalerWallet1/Views/Balances/PendingRow.swift @@ -0,0 +1,61 @@ +/* + * 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 PendingRow: View { + let amount: Amount + let incoming: Bool + let counterparty: String + var body: some View { + HStack { + Image(systemName: incoming ? "text.badge.plus" : "text.badge.minus") + .padding(.trailing) + .font(.largeTitle) + .foregroundColor(incoming ? Color("PendingIncoming") : Color("PendingOutgoing")) + + VStack(alignment: .leading) { + Text("\(counterparty)") + .font(.headline) + .fontWeight(.medium) + Text("Waiting for confirmation") + .font(.callout) + .padding(.vertical, -2.0) + Text("some time ago") // TODO: show time-interval + .font(.callout) + } + Spacer() + VStack(alignment: .trailing) { + let sign = incoming ? "+" : "-" + Text(sign + "\(amount.valueStr)") + .font(.title) + .foregroundColor(Color.gray) + Text("PENDING") + .font(.callout) + } + } + .padding(.top) + } +} + +struct PendingRow_Previews: PreviewProvider { + static var previews: some View { + Group { + PendingRow(amount: try! Amount(fromString: "Taler:4.8"), incoming: true, counterparty: "exchange.demo.taler.net") + PendingRow(amount: try! Amount(fromString: "Taler:3.25"), incoming: false, counterparty: "merchant") + } + } +} diff --git a/TalerWallet1/Views/Balances/WalletEmptyView.swift b/TalerWallet1/Views/Balances/WalletEmptyView.swift @@ -0,0 +1,44 @@ +/* + * 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 WalletEmptyView: View { + + var body: some View { + Form { + Section { + Text("There is no digital cash in your wallet.") + .padding() + } + Section { + Text("You can get test money from the demo bank:") + .padding() + } + Section { + Text("https://bank.demo.taler.net") + .padding() + } + } +// .multilineTextAlignment(.center) + .font(.title2) + } +} + +struct EmptyView_Previews: PreviewProvider { + static var previews: some View { + WalletEmptyView() + } +} diff --git a/TalerWallet1/Views/Exchange/ExchangeListView.swift b/TalerWallet1/Views/Exchange/ExchangeListView.swift @@ -0,0 +1,103 @@ +/* + * 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 SymLog + +struct ExchangeListView: View { + private let symLog = SymLogV() + let navTitle = "Exchanges" + + @ObservedObject var viewModel: ExchangeModel + + var body: some View { + let reloadAction = viewModel.updateList + VStack { + if viewModel.exchanges == nil { + symLog { LoadingView(backButtonHidden: false) } + } else { + Content(symLog: symLog, viewModel: viewModel, reloadAction: reloadAction) + .navigationTitle(navTitle) + } + }.task { + symLog.log(".task") + do { + try await reloadAction() + } catch { + // TODO: show error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension ExchangeListView { + struct Content: View { + let symLog: SymLogV + @ObservedObject var viewModel: ExchangeModel + var reloadAction: () async throws -> () + @State var showAlert: Bool = false + @State var newExchange: String = "https://exchange-age.taler.ar/" + + func addExchange(_ exUrl: String) -> Void { + Task { + do { + symLog.log("adding: \(exUrl)") + try await viewModel.add(url: exUrl) + symLog.log("added: \(exUrl)") + } catch { + symLog.log("error: \(error)") + // TODO: error handling - couldn't add exchangeURL + } + } + } + + var body: some View { + let plusAction: () -> Void = { +// withAnimation { showPopup = true } + showAlert = true + } + VStack { + if viewModel.exchanges!.isEmpty { + Text("No Exchanges yet...") + } else { + List(viewModel.exchanges!, id: \.self) { exchange in + VStack { + Text(exchange.exchangeBaseUrl) + .frame(maxWidth: .infinity) + .padding() + Text("Currency: " + (exchange.currency ?? "?")) + .frame(maxWidth: .infinity) + .padding() + } + } + .navigationBarTitleDisplayMode(.large) // .inline + .refreshable { + symLog.log("refreshing") + do { + try await reloadAction() + } catch { + // TODO: catch error + symLog.log(error.localizedDescription) + } + } + } + } + .navigationBarItems(trailing: PlusButton(action: plusAction)) + .textFieldAlert(isPresented: $showAlert, title: "Add Exchange", + doneText: "Add", text: $newExchange, action: addExchange) + } // body + } +} diff --git a/TalerWallet1/Views/Exchange/ExchangeModel.swift b/TalerWallet1/Views/Exchange/ExchangeModel.swift @@ -0,0 +1,115 @@ +/* + * 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 +import taler_swift +import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +class ExchangeModel: WalletModel { + @Published var exchanges: [Exchange]? +} +// MARK: - +/// A request to list exchanges. +fileprivate struct ListExchanges: WalletBackendFormattedRequest { + func operation() -> String { return "listExchanges" } + func args() -> Args { return Args() } + + struct Args: Encodable {} // no arguments needed + + struct Response: Decodable { // list of known exchanges + var exchanges: [Exchange] + } +} + +/// A request to add an exchange. +fileprivate struct AddExchange: WalletBackendFormattedRequest { + func operation() -> String { return "addExchange" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl) } + + var exchangeBaseUrl: String + + struct Args: Encodable { + var exchangeBaseUrl: String + } + + struct Response: Decodable {} +} +// MARK: - +struct Exchange: Codable, Hashable { + static func == (lhs: Exchange, rhs: Exchange) -> Bool { + return lhs.exchangeBaseUrl == rhs.exchangeBaseUrl && + lhs.exchangeStatus == rhs.exchangeStatus && + lhs.permanent == rhs.permanent + } + + var exchangeBaseUrl: String + var currency: String? + var tosStatus: String + var paytoUris: [String] + var exchangeStatus: String + var permanent: Bool + var ageRestrictionOptions: [Int] + var lastUpdateErrorInfo: ExchangeError? + + var name: String? { + if let url = URL(string: exchangeBaseUrl) { + if let host = url.host { + return host + } + } + return nil + } +} +struct ExchangeError: Codable, Hashable { + var error: HTTPError +} + +struct HTTPError: Codable, Hashable { + var code: Int + var requestUrl: String + var hint: String + var requestMethod: String + var httpStatusCode: Int? +} + +// MARK: - +extension ExchangeModel { + /// ask wallet-core for its list of known exchanges + @MainActor func updateList() async throws { + do { + let request = ListExchanges() + let response = try await sendRequest(request, ASYNCDELAY) + exchanges = response.exchanges // trigger view update in ExchangeListView + } catch { // TODO: Error + symLog?.log(error.localizedDescription) + throw error + } + } + + /// add a new exchange with URL to wallet's list of known exchanges + func add(url: String) async throws { + do { + symLog?.log("adding exchange: \(url)") // TODO: notice + let request = AddExchange(exchangeBaseUrl: url) + _ = try await sendRequest(request) + symLog?.log("added exchange: \(url)") + try await updateList() + } catch { // TODO: Error + symLog?.log(error.localizedDescription) + throw error + } + } +} diff --git a/TalerWallet1/Views/HelperViews/AmountView.swift b/TalerWallet1/Views/HelperViews/AmountView.swift @@ -0,0 +1,43 @@ +/* + * 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 AmountView: View { + let title: String + let value: String + let color: Color + var body: some View { + VStack { + Text(title) + .font(.title3) + Text(value) + .font(.largeTitle) + .fontWeight(.medium) + .foregroundColor(color) + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowSeparator(.hidden) + } +} + +struct AmountView_Previews: PreviewProvider { + static var previews: some View { + Form { + AmountView(title: "Fee", value: "- 0,2 Taler", color: Color("Outgoing")) + AmountView(title: "Coins", value: "4,8 Taler", color: Color("Incoming")) + } + } +} diff --git a/TalerWallet1/Views/HelperViews/Buttons.swift b/TalerWallet1/Views/HelperViews/Buttons.swift @@ -0,0 +1,86 @@ +/* + * 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 HamburgerButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "line.3.horizontal") + } + .font(.title) + } +} + +struct PlusButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "plus") + } + .font(.title) + } +} + +struct ReloadButton : View { + let disabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "arrow.clockwise") + } + .font(.title) + .disabled(disabled) + } +} + +struct AwesomeButton: View { + let title: String + let action: () -> Void + var body: some View { + Button(action: action) { + Text(title) + .frame(minWidth: 0, maxWidth: 300) + .padding() + .foregroundColor(.white) + .background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.blue]), startPoint: .leading, endPoint: .trailing)) + .cornerRadius(40) + .font(.title) + } + } +} + +struct Buttons_Previews: PreviewProvider { + static var previews: some View { + VStack { + HamburgerButton() {} + .padding() + PlusButton() {} + .padding() + HStack { + ReloadButton(disabled: false) {} + .padding() + ReloadButton(disabled: true) {} + .padding() + } + AwesomeButton(title: "AwesomeButton") {} + .padding() + } + } +} diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift @@ -0,0 +1,45 @@ +/* + * 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 SymLog + +struct LoadingView: View { + private let symLog = SymLogV(0) + let backButtonHidden: Bool + + var body: some View { + symLog { NavigationView { + VStack { + Spacer() + ProgressView() + Spacer() + Spacer() + Spacer() + } + .navigationBarBackButtonHidden(backButtonHidden) + .navigationTitle("Loading...") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .background(Color(.systemGray6)) + } + } +} + +struct LoadingView_Previews: PreviewProvider { + static var previews: some View { + LoadingView(backButtonHidden: true) + } +} diff --git a/TalerWallet1/Views/HelperViews/TextFieldAlert.swift b/TalerWallet1/Views/HelperViews/TextFieldAlert.swift @@ -0,0 +1,73 @@ +/* + * 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 TextFieldAlert: ViewModifier { + @Binding var isPresented: Bool + let title: String + let doneText: String + @Binding var text: String + let placeholder: String + let action: (String) -> Void + func body(content: Content) -> some View { + ZStack(alignment: .center) { + content + .disabled(isPresented) + if isPresented { + VStack { + Text(title).font(.headline).padding() + TextField(placeholder, text: $text).textFieldStyle(.roundedBorder).padding() + Divider() + HStack { + Spacer() + Button(role: .cancel) { + withAnimation { isPresented.toggle() } + } label: { + Text("Cancel") + } + Spacer() + Divider() + Spacer() + Button(doneText) { + action(text) + withAnimation { isPresented.toggle() } + } + Spacer() + } + } + .background(.background) + .frame(width: 300, height: 200) + .cornerRadius(20) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(.quaternary, lineWidth: 1) + } + } + } + } +} + +extension View { + public func textFieldAlert(isPresented: Binding<Bool>, + title: String, + doneText: String, + text: Binding<String>, + placeholder: String = "", + action: @escaping (String) -> Void + ) -> some View { + self.modifier(TextFieldAlert(isPresented: isPresented, title: title, doneText: doneText, text: text, placeholder: placeholder, action: action)) + } +} diff --git a/TalerWallet1/Views/Main/ContentView.swift b/TalerWallet1/Views/Main/ContentView.swift @@ -0,0 +1,92 @@ +/* + * 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 SymLog + +extension URL: Identifiable { + public var id: URL {self} +} + +struct ContentView: View { + private let symLog = SymLogV() + @EnvironmentObject private var controller: Controller + @State private var sheetPresented = false + @State private var urlToOpen: URL? = nil + + var body: some View { + if controller.backendState == .ready { + symLog { + Content(symLog: symLog, controller: controller) + .onAppear { // called e.g. after coming to foreground + symLog.log(".onAppear") + } + .onOpenURL { url in + // TODO: check if this is called when launching the app from the camera scanning a QR code + symLog.log(".onOpenURL: \(url)") + urlToOpen = url + } + .sheet(item: $urlToOpen, onDismiss: {}) { url in + URLSheet(urlToOpen: url) + } + } // symLog + } else if controller.backendState == .error { + ErrorView() // TODO: show Error View + } else { + LaunchAnimationView() + } + } +} +// MARK: - Content +extension ContentView { + struct Content: View { + let symLog: SymLogV? + var controller: Controller + + @State var sidebarVisible: Bool = false + @State var currentView: Int = 0 + var views: [SidebarItem] {[ + SidebarItem(name: "Balances", + sysImage: "creditcard.fill", // TODO: Wallet Icon + view: AnyView(CurrenciesListView(viewModel: controller.balancesModel) + { sidebarVisible = true } + )), + SidebarItem(name: "Settings", + sysImage: "gearshape.fill", + view: AnyView(SettingsView() + { sidebarVisible = true } + )), + SidebarItem(name: "Pending Operations", + sysImage: "arrow.triangle.2.circlepath", + view: AnyView(PendingOpsListView(viewModel: controller.pendingModel) + { sidebarVisible = true } + )) + ]} + var body: some View { + ZStack(alignment: .leading) { + views[currentView].view + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + SideBarView(views: views, currentView: $currentView, sidebarVisible: $sidebarVisible) + } + .background(Color(.systemGray6)) + } + } +} +// MARK: - +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// ContentView.Content(symLog: nil, controller: controller) +// } +//} diff --git a/TalerWallet1/Views/Main/ErrorView.swift b/TalerWallet1/Views/Main/ErrorView.swift @@ -0,0 +1,31 @@ +/* + * 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 SymLog + +struct ErrorView: View { + private let symLog = SymLogV() + var body: some View { + + Text("Couldn't load Wallet-Core!") + } +} + +struct ErrorView_Previews: PreviewProvider { + static var previews: some View { + ErrorView() + } +} diff --git a/TalerWallet1/Views/Main/LaunchAnimationView.swift b/TalerWallet1/Views/Main/LaunchAnimationView.swift @@ -0,0 +1,33 @@ + +import SwiftUI +import SymLog + +struct LaunchAnimationView: View { + private let symLog = SymLogV(0) + @State private var rotationDirection = false + + private let animationTimer = Timer + .publish(every: 1.4, on: .current, in: .common) + .autoconnect() + + var body: some View { + ZStack { + Color.teal.ignoresSafeArea() + Image(systemName: "hurricane") + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .rotationEffect(rotationDirection ? Angle(degrees: 0) : Angle(degrees: 1080)) + } + .onReceive(animationTimer) { timerValue in + withAnimation(.easeInOut(duration: 1.9)) { + rotationDirection.toggle() + } + } + } +} +struct LaunchAnimationView_Previews: PreviewProvider { + static var previews: some View { + LaunchAnimationView() + } +} diff --git a/TalerWallet1/Views/Main/SideBarView.swift b/TalerWallet1/Views/Main/SideBarView.swift @@ -0,0 +1,110 @@ +/* + * 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 SymLog + +fileprivate let sidebarWidth = 250.0 + +struct SidebarItem { + var name: String + var sysImage: String? + var view: AnyView +} + +struct SideBarView: View { + private let symLog = SymLogV(0) + var views: [SidebarItem] + @Binding var currentView: Int + @Binding var sidebarVisible: Bool + + var body: some View { + symLog { + HStack { + VStack { + Spacer() + ForEach(0..<views.count, id: \.self) { i in + Button { + symLog.log("sidebar item \"\(views[i].name)\" selected") + sidebarVisible = false // slide sidebar to the left + currentView = i // switch to the view the user selected + } label: { + if let sysImage = views[i].sysImage { + Label(views[i].name, systemImage: sysImage) + .frame(maxWidth: sidebarWidth, alignment: .leading) + } else { + Text(views[i].name) + .frame(maxWidth: sidebarWidth) + } + } + .buttonStyle(.bordered) + .font(.title) +// .padding(.vertical) + +// Divider() + } + Spacer() + Spacer() + } + .background(Color(.systemGray5)) + .frame(width: sidebarWidth, alignment: .center) + // TODO: use leading instead of sidebarWidth for right-to-left + .offset(x: sidebarVisible ? 0 : -sidebarWidth) + .animation(.easeInOut, value: sidebarVisible) + .ignoresSafeArea() + // .onAppear can NOT be used here, because we don't show or dismiss this view, + // but only slide it left or right - so it is always there. + // .onAppear {} would be called once even before LaunchScreen is dismissed and then never again + + // this is just a target for a tap gesture outside the sidebar to dismiss it + Color.clear + .frame(maxWidth: sidebarVisible ? .infinity : 0, maxHeight: .infinity, alignment: .leading) + // TODO: right-to-left ? + .offset(x: sidebarVisible ? sidebarWidth : 0) + .contentShape(Rectangle()) + .onTapGesture { + sidebarVisible = false + } + } + } + } +} + +#if DEBUG +struct BindingViewContainer : View { + @State var currentView: Int = 0 + @State var sidebarVisible: Bool = true + var views: [SidebarItem] + + var body: some View { + ZStack(alignment: .leading) { + views[currentView].view + SideBarView(views: views, currentView: $currentView, sidebarVisible: $sidebarVisible) + } + } +} + +struct SideBarView_Previews: PreviewProvider { + static var views: [SidebarItem] {[ + SidebarItem(name: "Balances", + view: AnyView(WalletEmptyView())), + SidebarItem(name: "Settings", + view: AnyView(WalletEmptyView())) + ]} + static var previews: some View { + BindingViewContainer(views: views) + } +} +#endif diff --git a/TalerWallet1/Views/Payment/PaymentAcceptView.swift b/TalerWallet1/Views/Payment/PaymentAcceptView.swift @@ -0,0 +1,71 @@ +/* + * 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 +import SymLog + +struct PaymentAcceptView: View { + private let symLog = SymLogV() + @ObservedObject var viewModel: PaymentURIModel + + var detailsForAmount: PaymentDetailsForUri + +// @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + let navTitle = "Accept Payment" + var cancelButton: some View { + Button("Cancel6") { dismissTop() } + } + + @State var confirmPayResult: ConfirmPayResult? + + var body: some View { + symLog { Group { + let raw = detailsForAmount.amountRaw + let effective = detailsForAmount.amountEffective + let fee = try! effective - raw + Form { + AmountView(title: "Amount to pay:", + value: raw.readableDescription, color: Color(UIColor.label)) + .padding(.bottom) + AmountView(title: "Exchange fee:", + value: fee.readableDescription, color: Color("Outgoing")) + .padding(.bottom) + AmountView(title: "Coins to be spent:", + value: effective.readableDescription, color: Color("Outgoing")) + } + AwesomeButton(title: "Accept") { + Task { + do { + confirmPayResult = try await viewModel.confirmPay(detailsForAmount.proposalId) + symLog.log(confirmPayResult as Any) + if confirmPayResult?.type == "done" { + // TODO: Show Hints that Payment was successfull + // success + } else { + // TODO: show error + } + } catch { + symLog.log(error.localizedDescription) // TODO: error + } + dismissTop() + } + } + } + .navigationBarItems(leading: cancelButton) + .navigationTitle(navTitle) + } + } +} diff --git a/TalerWallet1/Views/Payment/PaymentURIModel.swift b/TalerWallet1/Views/Payment/PaymentURIModel.swift @@ -0,0 +1,183 @@ +/* + * 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 +import taler_swift +import AnyCodable +//import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +enum PaymentState { + case error + case waitingForUriDetails + case receivedUriDetails + case waitingForPaymentAck + case receivedPaymentAck +} + +class PaymentURIModel: WalletModel { + @Published var paymentState: PaymentState? +} + + +// MARK: - ContractTerms +struct ContractTerms: Codable { + let amount: Amount + let maxFee: Amount + let maxWireFee: Amount + let merchant: Merchant + let extra: Extra + let summary: String + let timestamp: Timestamp + let payDeadline: Timestamp + let refundDeadline: Timestamp + let wireTransferDeadline: Timestamp + let merchantBaseURL: String + let fulfillmentURL: String + let publicReorderURL: String + let auditors: [Auditor] + let exchanges: [ExchangeForPay] + let orderID, nonce, merchantPub: String + let products: [Product] + let hWire: String + let wireMethod: String + let wireFeeAmortization: Int + + enum CodingKeys: String, CodingKey { + case amount + case maxFee = "max_fee" + case maxWireFee = "max_wire_fee" + case merchant, extra, summary + case timestamp + case payDeadline = "pay_deadline" + case refundDeadline = "refund_deadline" + case wireTransferDeadline = "wire_transfer_deadline" + case merchantBaseURL = "merchant_base_url" + case fulfillmentURL = "fulfillment_url" + case publicReorderURL = "public_reorder_url" + case auditors, exchanges + case orderID = "order_id" + case nonce + case merchantPub = "merchant_pub" + case products + case hWire = "h_wire" + case wireMethod = "wire_method" + case wireFeeAmortization = "wire_fee_amortization" + } +} + +// MARK: - Auditor +struct Auditor: Codable { + let name: String + let auditorPub: String + let url: String + + enum CodingKeys: String, CodingKey { + case name + case auditorPub = "auditor_pub" + case url + } +} + +// MARK: - Exchange +struct ExchangeForPay: Codable { + let url: String + let masterPub: String + + enum CodingKeys: String, CodingKey { + case url + case masterPub = "master_pub" + } +} + +// MARK: - Extra +struct Extra: Codable { + let articleName: String + + enum CodingKeys: String, CodingKey { + case articleName = "article_name" + } +} + +// MARK: - +/// The result from PreparePayForUri +struct PaymentDetailsForUri: Codable { + let status: String + let amountRaw: Amount + let amountEffective: Amount + let noncePriv: String + let proposalId: String + let contractTerms: ContractTerms + let contractTermsHash: String +} +/// A request to get an exchange's payment contract terms. +fileprivate struct PreparePayForUri: WalletBackendFormattedRequest { + typealias Response = PaymentDetailsForUri + func operation() -> String { return "preparePayForUri" } + func args() -> Args { return Args(talerPayUri: talerPayUri) } + + var talerPayUri: String + struct Args: Encodable { + var talerPayUri: String + } +} +// MARK: - +/// The result from getPaymentDetailsForAmount +struct ConfirmPayResult: Decodable { + var type: String + var contractTerms: ContractTerms + var transactionId: String +} +/// A request to get an exchange's payment details. +fileprivate struct confirmPayForUri: WalletBackendFormattedRequest { + typealias Response = ConfirmPayResult + func operation() -> String { return "confirmPay" } + func args() -> Args { return Args(proposalId: proposalId) } + + var proposalId: String + struct Args: Encodable { + var proposalId: String + } +} +// MARK: - +extension PaymentURIModel { + /// load payment details. Networking involved + @MainActor + func preparePayForUri(_ talerPayUri: String) async throws -> PaymentDetailsForUri { + do { + paymentState = .waitingForUriDetails + let request = PreparePayForUri(talerPayUri: talerPayUri) + let response = try await sendRequest(request, ASYNCDELAY) + paymentState = .receivedUriDetails + return response + } catch { + paymentState = .error + throw error + } + } + @MainActor + func confirmPay(_ proposalId: String) async throws -> ConfirmPayResult { + do { + paymentState = .waitingForPaymentAck + let request = confirmPayForUri(proposalId: proposalId) + let response = try await sendRequest(request, ASYNCDELAY) + paymentState = .receivedPaymentAck + return response + } catch { + paymentState = .error + throw error + } + } +} diff --git a/TalerWallet1/Views/Payment/PaymentURIView.swift b/TalerWallet1/Views/Payment/PaymentURIView.swift @@ -0,0 +1,65 @@ +/* + * 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 SymLog + +struct PaymentURIView: View { + private let symLog = SymLogV() + var url: URL + @ObservedObject var viewModel: PaymentURIModel + +// @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + let navTitle = "Payment" + var cancelButton: some View { + Button("Cancel5") { dismissTop() } + } + + @State var detailsForUri: PaymentDetailsForUri? + + var body: some View { + let badURL = "Error in URL: \(url)" + VStack { + if viewModel.paymentState == nil { + LoadingView(backButtonHidden: false) + } else { switch viewModel.paymentState { + case .waitingForUriDetails: + let _ = symLog.vlog("waitingForUriDetails") + WithdrawProgressView(message: url.host ?? badURL) { dismissTop() } + .navigationTitle("Contacting Exchange") + case .receivedUriDetails: + let _ = symLog.vlog("waitingForUser") + PaymentAcceptView(viewModel: viewModel, detailsForAmount: detailsForUri!) + default: + symLog { + Text("Payment") + .navigationBarItems(leading: cancelButton) + .navigationTitle(navTitle) + } + } } + }.task { + do { // TODO: cancelled + symLog.log(".task") + detailsForUri = try await viewModel.preparePayForUri(url.absoluteString) +// print(detailsForUri?.status) +// print(detailsForUri?.amountRaw.description) +// print(detailsForUri?.amountEffective.description) +// print(detailsForUri?.proposalId) + } catch { + symLog.log(error.localizedDescription) // TODO: error + } + } + } +} diff --git a/TalerWallet1/Views/Pending/PendingModel.swift b/TalerWallet1/Views/Pending/PendingModel.swift @@ -0,0 +1,82 @@ +/* + * 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 +import AnyCodable +import taler_swift +import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +class PendingModel: WalletModel { + @Published var pendingOperations: [PendingOperation]? +} +// MARK: - +/// A request to list the backend's currently pending operations. +fileprivate struct GetPendingOperations: WalletBackendFormattedRequest { + func operation() -> String { return "getPendingOperations" } + func args() -> Args { Args() } + + struct Args: Encodable {} + + struct Response: Decodable { + var pendingOperations: [PendingOperation] + } +} +// MARK: - +struct PendingOperation: Codable, Hashable { + var type: String + var exchangeBaseUrl: String + var id: String + var isLongpolling: Bool + var givesLifeness: Bool + var isDue: Bool + var timestampDue: Timestamp + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(exchangeBaseUrl) + hasher.combine(id) + hasher.combine(isLongpolling) + hasher.combine(givesLifeness) + hasher.combine(isDue) + hasher.combine(timestampDue) + } + +} +//let pending1 = ["type": "exchange-update", +// "exchangeBaseUrl": "https://exchange.demo.taler.net/", +// "id": "exchange-update:https://exchange.demo.taler.net/", +// "timestampDue": ["t_ms": 1669931055000], +// "isDue": false, +// "isLongpolling": false, +// "givesLifeness": false] as [String : Any] +// +//let pending2 = ["type": "exchange-check-refresh", +// "exchangeBaseUrl": "https://exchange.demo.taler.net/", +// "id": "exchange-update:https://exchange.demo.taler.net/", +// "timestampDue": ["t_ms": 1670013862000], +// "isDue": false, +// "isLongpolling": false, +// "givesLifeness": false] as [String : Any] +// MARK: - +extension PendingModel { + @MainActor func update() async throws { + do { + let request = GetPendingOperations() + let response = try await sendRequest(request, ASYNCDELAY) + pendingOperations = response.pendingOperations + } + } +} diff --git a/TalerWallet1/Views/Pending/PendingOpView.swift b/TalerWallet1/Views/Pending/PendingOpView.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 +import taler_swift + +struct PendingOpView: View { + var pendingOp: PendingOperation + @State var polling: Bool = false + @State var liveliness: Bool = false + @State var isDue: Bool = false + + var body: some View { + Section { + Text(pendingOp.exchangeBaseUrl) + Text(pendingOp.id) + .font(.caption) + Toggle("isLongPolling", isOn: $polling) + .disabled(true) + Toggle("givesLifeness", isOn: $liveliness) + .disabled(true) + Toggle("isDue", isOn: $isDue) + .disabled(true) + let dateString = TalerDater.dateString(from: pendingOp.timestampDue) + Text("\(dateString)") + } header: { + Text(pendingOp.type) + .font(.title2) + } +// .textCase(nil) // don't capitalize + .onAppear { + polling = pendingOp.isLongpolling + liveliness = pendingOp.givesLifeness + isDue = pendingOp.isDue + } + } +} + +struct PendingOpView_Previews: PreviewProvider { + static var pending1 = PendingOperation(type: "exchange-check-refresh", + exchangeBaseUrl: "https://exchange.demo.taler.net/", + id: "exchange-update:https://exchange.demo.taler.net/", + isLongpolling: false, + givesLifeness: true, + isDue: false, + timestampDue: Timestamp(from:1669931055000)) + static var previews: some View { + Form { + PendingOpView(pendingOp: pending1) + } + } +} diff --git a/TalerWallet1/Views/Pending/PendingOpsListView.swift b/TalerWallet1/Views/Pending/PendingOpsListView.swift @@ -0,0 +1,65 @@ +/* + * 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 SymLog + +struct PendingOpsListView: View { + private let symLog = SymLogV() + let navTitle = "Pending" + + @ObservedObject var viewModel: PendingModel + var hamburgerAction: () -> Void + + var body: some View { + let reloadAction = viewModel.update + VStack { + if viewModel.pendingOperations == nil { + symLog { LoadingView(backButtonHidden: true) } + } else { + symLog { NavigationView { + Content(symLog: symLog, viewModel: viewModel, reloadAction: reloadAction) + .navigationBarItems(leading: HamburgerButton(action: hamburgerAction)) + } } + .navigationTitle(navTitle) + } + }.task { + symLog.log(".task") + try? await reloadAction() // TODO: catch error + } + } +} +// MARK: - +extension PendingOpsListView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var viewModel: PendingModel +// @EnvironmentObject var controller : Controller + var reloadAction: () async throws -> () + + var body: some View { + Group { + List(viewModel.pendingOperations!, id: \.self) { pendingOp in + PendingOpView(pendingOp: pendingOp) + } + .navigationBarTitleDisplayMode(.large) // .inline + .refreshable { + symLog?.log("refreshing") + try? await reloadAction() // TODO: catch error + } + } + } + } +} diff --git a/TalerWallet1/Views/Settings/SettingsItem.swift b/TalerWallet1/Views/Settings/SettingsItem.swift @@ -0,0 +1,93 @@ +/* + * 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 SettingsItem<Content: View>: View { + var name: String + var description: String? + var content: () -> Content + + init(name: String, description: String? = nil, @ViewBuilder content: @escaping () -> Content) { + self.name = name + self.description = description + self.content = content + } + + var body: some View { + HStack { + VStack { + Text(name) + .frame(maxWidth: .infinity, alignment: .leading) + .font(.title2) + .padding([.bottom], 0.01) + if let desc = description { + Text(desc) + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption) + } + } + content() + }.padding([.bottom], 4) + } +} + +struct SettingsToggle: View { + var name: String + @Binding var value: Bool + var description: String? + + var body: some View { + VStack { + Toggle(name, isOn: $value.animation(.spring())) + .font(.title2) + if let desc = description { + Text(desc) + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption) + } + }.padding([.bottom], 4) + } +} + + + +struct SettingsItemPreview : View { + @State var developerMode: Bool = false + + var body: some View { + VStack { + SettingsToggle(name: "Developer Mode", value: $developerMode, description: "More information intended for debugging") + } + } +} + +struct SettingsItem_Previews: PreviewProvider { + static var previews: some View { + List { + NavigationLink { } label: { + SettingsItem (name: "Exchanges", description: "Manage list of exchanges known to this wallet") {} + } + SettingsItemPreview() + SettingsItem(name: "Save Logfile", description: "Help debugging wallet-core") { + Button("Save") { + } + .buttonStyle(.bordered) + .disabled(true) + } + } + } +} diff --git a/TalerWallet1/Views/Settings/SettingsView.swift b/TalerWallet1/Views/Settings/SettingsView.swift @@ -0,0 +1,139 @@ +/* + * 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 +import SymLog + +/* + * Backup + * Last backup: 5 hr. ago + * + * + * Debug log + * View/send internal log + * + * + * Reset Wallet (dangerous!) + * Throws away your money + */ + +struct SettingsView: View { + private let symLog = SymLogV(0) + + @EnvironmentObject var controller: Controller + @AppStorage("developerMode") var developerMode: Bool = false + @AppStorage("developDelay") var developDelay: Bool = false + + var showSidebar: () -> Void + init(showSidebar: @escaping () -> Void) { + self.showSidebar = showSidebar + } + + @State private var checkDisabled = false + @State private var withDrawDisabled = false + + var body: some View { + symLog { NavigationView { + List { + NavigationLink { + ExchangeListView(viewModel: controller.exchangeModel) + } label: { + SettingsItem(name: "Exchanges", description: "Manage list of exchanges known to this wallet") {} + } + SettingsToggle(name: "Developer Mode", value: $developerMode, + description: "More information intended for debugging") + if developerMode { // show or hide the following items + let walletCore = controller.walletCore + SettingsToggle(name: "Set 2 seconds delay", value: $developDelay, + description: "After each wallet-core action") + .onChange(of: developDelay, perform: { developDelay in + walletCore.developDelay = developDelay + }) + + SettingsItem(name: "Withdraw KUDOS", description: "Get money for testing") { + Button("Withdraw") { + withDrawDisabled = true // don't run twice + let testModel: ExchangeTestModel = ExchangeTestModel(walletCore: walletCore) + symLog.log("Withdrawing ") + testModel.loadTestKudos() + } + .buttonStyle(.bordered) + .disabled(withDrawDisabled) + } + SettingsItem(name: "Run Integration Test", description: "Check if wallet-core works") { + Button("Check") { + checkDisabled = true // don't run twice + let testModel: ExchangeTestModel = ExchangeTestModel(walletCore: walletCore) + symLog.log("running integration test ") + testModel.runIntegrationTest() + } + .buttonStyle(.bordered) + .disabled(checkDisabled) + } + SettingsItem(name: "Save Logfile", description: "Help debugging wallet-core") { + Button("Save") { + symLog.log("Saving Log") + // FIXME: Save Logfile + } + .buttonStyle(.bordered) + .disabled(true) + } + VStack { + SettingsItem(name: "App Version") { + Text("\(Bundle.main.releaseVersionNumberPretty)") + } + SettingsItem(name: "Wallet Core Version") { + Text("\(walletCore.versionInfo!.version)") + } + SettingsItem(name: "Wallet Core DevMode") { + Text("\(walletCore.versionInfo!.devMode ? "YES" : "NO")") + } + SettingsItem(name: "Supported Exchange Versions") { + Text("\(walletCore.versionInfo!.exchange)") + } + SettingsItem(name: "Supported Merchant Versions") { + Text("\(walletCore.versionInfo!.merchant)") + } + SettingsItem(name: "Used Bank") { + Text("\(walletCore.versionInfo!.bank)") + } + } + } + } + .navigationTitle("Settings") + .navigationBarItems(leading: HamburgerButton(action: showSidebar)) + } } // symLog + } +} +extension Bundle { + var releaseVersionNumber: String? { + return infoDictionary?["CFBundleShortVersionString"] as? String + } + var buildVersionNumber: String? { + return infoDictionary?["CFBundleVersion"] as? String + } + var releaseVersionNumberPretty: String { + return "v\(releaseVersionNumber ?? "1.0.0")" + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView { + + } + } +} diff --git a/TalerWallet1/Views/Transactions/TransactionDetail.swift b/TalerWallet1/Views/Transactions/TransactionDetail.swift @@ -0,0 +1,79 @@ +/* + * 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 TransactionDetail: View { + var transaction : Transaction + + var body: some View { + let raw = transaction.amountRaw + let effective = transaction.amountEffective + let fee = try! Amount.diff(raw, effective) + let dateString = TalerDater.dateString(from: transaction.timestamp) + + VStack() { + Spacer() + Text("\(dateString)") + .font(.title) + .fontWeight(.medium) + .padding(.bottom) + AmountView(title: "Chosen amount to withdraw:", + value: raw.readableDescription, color: Color(UIColor.label)) + .padding(.bottom) + AmountView(title: "Exchange fee:", + value: fee.readableDescription, color: Color("Outgoing")) + .padding(.bottom) + AmountView(title: "Obtained coins:", + value: effective.readableDescription, color: Color("Incoming")) + .padding(.bottom) + if let baseURL = transaction.exchangeBaseUrl { + VStack { + Text("From exchange:") + .font(.title3) + Text("\(baseURL.trimURL())") + .font(.title) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity, alignment: .center) + } + Spacer() + Button(role: .destructive, action: { + // TODO: delete from wallet-core + print("Should delete \(transaction.transactionId)") + }, label: { + HStack { + Text("Delete from list" + " ") + Image(systemName: "trash") + } + .font(.title) + .frame(maxWidth: .infinity) + }) + .buttonStyle(.bordered) + .controlSize(.large) +// Spacer() + } + } +} + +#if DEBUG +struct TransactionDetail_Previews: PreviewProvider { + static var transaction = Transaction(id:"some transActionID", time: Timestamp(from: 1_666_000_000_000)) + static var previews: some View { + TransactionDetail(transaction: transaction) + } +} +#endif diff --git a/TalerWallet1/Views/Transactions/TransactionRow.swift b/TalerWallet1/Views/Transactions/TransactionRow.swift @@ -0,0 +1,81 @@ +/* + * 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 TransactionRowCenter: View { + var centerTop: String + var centerBottom: String + + var body: some View { + VStack(alignment: .leading) { + Text("\(centerTop)") + .font(.headline) + .fontWeight(.medium) + .padding(.bottom, -2.0) + Text("\(centerBottom)") + .font(.callout) + } + } +} + +struct TransactionRow: View { + var transaction : Transaction + + var body: some View { + let amount = transaction.amountEffective + let withdraw: Bool = transaction.type == "withdrawal" + let payment: Bool = transaction.type == "payment" + let refund: Bool = transaction.type == "refund" + let incoming = withdraw || refund + let counterparty = transaction.exchangeBaseUrl + let dateString = TalerDater.dateString(from: transaction.timestamp, relative: true) + + HStack { + Image(systemName: incoming ? "text.badge.plus" : "text.badge.minus") + .foregroundColor(incoming ? Color("Incoming") : Color("Outgoing")) + .padding(.trailing) + .font(.largeTitle) + + if withdraw { + if let baseURL = counterparty { + TransactionRowCenter(centerTop: baseURL.trimURL(), centerBottom: dateString) + } + } else if payment { + TransactionRowCenter(centerTop: "Payment", centerBottom: dateString) + } else if refund { + TransactionRowCenter(centerTop: "Refund", centerBottom: dateString) + } + Spacer() + VStack(alignment: .trailing) { + let sign = incoming ? "+" : "-" + Text(sign + "\(amount.valueStr)") + .font(.title) + .foregroundColor(incoming ? Color("Incoming") : Color("Outgoing")) + } + } + .padding(.top) + } +} + +#if DEBUG +struct TransactionRow_Previews: PreviewProvider { + static var transaction = Transaction(id:"some transActionID", time: Timestamp(from: 1_666_000_000_000)) + static var previews: some View { + TransactionRow(transaction: transaction) + } +} +#endif diff --git a/TalerWallet1/Views/Transactions/TransactionsListView.swift b/TalerWallet1/Views/Transactions/TransactionsListView.swift @@ -0,0 +1,91 @@ +/* + * 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 SymLog + +struct TransactionsListView: View { + private let symLog = SymLogV() + let navTitle = "Transactions" + + @ObservedObject var viewModel: TransactionsModel + + var body: some View { + let reloadAction = viewModel.fetchTransactions + VStack { + if viewModel.transactions == nil { + symLog { LoadingView(backButtonHidden: false) } + } else { + symLog { Content(symLog: symLog, viewModel: viewModel, reloadAction: reloadAction) + .navigationTitle(navTitle) + } + } + }.task { + symLog.log(".task") + do { + try await reloadAction() + } catch { + // TODO: show error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension TransactionsListView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var viewModel: TransactionsModel + + var reloadAction: () async throws -> () + + var body: some View { + let transactions = viewModel.transactions! + List(transactions, id: \.transactionId) { transaction in + NavigationLink { + TransactionDetail(transaction: transaction) + } label: { + TransactionRow(transaction: transaction) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + symLog?.log("bookmarked \(transaction.transactionId)") + // TODO: Bookmark + } label: { + Label("Bookmark", systemImage: "bookmark") + }.tint(.indigo) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + symLog?.log("deleted \(transaction.transactionId)") + // TODO: delete from Model. SwiftUI deletes this row from view already :-) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .navigationBarTitleDisplayMode(.large) // .inline + .refreshable { + symLog?.log("refreshing") + try? await reloadAction() // TODO: catch error + } + } + } +} +//struct TransactionsView_Previews: PreviewProvider { +// static var previews: some View { +// TransactionsView() +// } +//} diff --git a/TalerWallet1/Views/Transactions/TransactionsModel.swift b/TalerWallet1/Views/Transactions/TransactionsModel.swift @@ -0,0 +1,69 @@ +/* + * 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 +import taler_swift +import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +// MARK: - +class TransactionsModel: WalletModel { + @Published var transactions: [Transaction]? // update view +} +//extension Transaction { +// func exchangeBaseUrl() -> String { +// switch detail { +// case .withdrawal(let transactionWithdrawal): +// return transactionWithdrawal.exchangeBaseUrl +// } +// } +//} + +// MARK: - +/// A request to get the transactions in the wallet's history. +fileprivate struct GetTransactions: WalletBackendFormattedRequest { + func operation() -> String { return "getTransactions" } + func args() -> Args { return Args(currency: currency, search: search) } + + var currency: String? + var search: String? + + struct Args: Encodable { + var currency: String? + var search: String? + } + + struct Response: Decodable { // list of transactions + var transactions: [Transaction] + } +} +// MARK: - +extension TransactionsModel { + /// ask wallet-core for its list of transactions filtered by searchString + func fetchTransactions() async throws { // might be called from a background thread itself + try await fetchTransactions(currency: nil, searchString: nil) + } + /// fetch Balances from Wallet-Core. No networking involved + @MainActor func fetchTransactions(currency: String? = nil, searchString: String? = nil) + async throws { + do { + let request = GetTransactions(currency: nil, search: nil) + let response = try await sendRequest(request, ASYNCDELAY) + transactions = response.transactions // trigger view update in TransactionsListView + } catch { + throw error + } + } +} diff --git a/TalerWallet1/Views/URLSheet.swift b/TalerWallet1/Views/URLSheet.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 +import SymLog + +struct URLSheet: View { + private let symLog = SymLogV() + var urlToOpen: URL + @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + @EnvironmentObject private var controller: Controller + + @State private var urlCommand: UrlCommand? = nil + + var cancelButton: some View { + Button("Cancel0") { + print(dismiss) + dismissTop() + } + } + + var body: some View { + symLog { + NavigationView { + if urlCommand == UrlCommand.withdraw { + WithdrawURIView(url: urlToOpen, viewModel: controller.withdrawURIModel) + } else if urlCommand == UrlCommand.pay { + PaymentURIView(url: urlToOpen, viewModel: controller.paymentURIModel) + } else { + VStack { // show Error view with cancelButton + Spacer() + Text(controller.messageForSheet ?? urlToOpen.absoluteString) + .font(.title) + Spacer() + Spacer() + } + .navigationBarItems(leading: cancelButton) + .navigationTitle("Invalid URL") + } + }.task { + urlCommand = controller.openURL(urlToOpen) + } + } + } +} +// MARK: - +//struct PaySheet_Previews: PreviewProvider { +// static var previews: some View { + // needs BackendManager +// URLSheet(urlToOpen: URL(string: "ftp://this.URL.is.invalid")!) +// } +//} diff --git a/TalerWallet1/Views/Withdraw/WithdrawAcceptView.swift b/TalerWallet1/Views/Withdraw/WithdrawAcceptView.swift @@ -0,0 +1,71 @@ +/* + * 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 +import SymLog + +struct WithdrawAcceptView: View { + private let symLog = SymLogV() + var url: URL + @ObservedObject var model: WithdrawURIModel + +// @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + let navTitle = "Accept Withdrawal" + var cancelButton: some View { + Button("Cancel4") { dismissTop() } + } + + let detailsForAmount: WithdrawalDetailsForAmount + let baseURL: String + + var body: some View { + symLog { Group { + switch model.withdrawState { + case .receivedAmountDetails, .receivedTOS, .receivedTOSAck: + let raw = detailsForAmount.amountRaw + let effective = detailsForAmount.amountEffective + let fee = try! raw - effective + Form { + AmountView(title: "Chosen amount to withdraw:", + value: raw.readableDescription, color: Color(UIColor.label)) + .padding(.bottom) + AmountView(title: "Exchange fee:", + value: "- " + fee.readableDescription, color: Color("Outgoing")) + .padding(.bottom) + AmountView(title: "Coins to be withdrawn:", + value: effective.readableDescription, color: Color("Incoming")) + } + AwesomeButton(title: "Accept") { + Task { + do { + let bankConfirmationUrl = try await model.sendAcceptIntWithdrawal(baseURL, withdrawURL: url.absoluteString) + symLog.log(bankConfirmationUrl as Any) + // TODO: Show Hints that User should Confirm on bank website + } catch { + symLog.log(error.localizedDescription) + } + dismissTop() + } + } + default: + ErrorView() + } + } + .navigationBarItems(leading: cancelButton) + .navigationTitle(navTitle) + } + } +} diff --git a/TalerWallet1/Views/Withdraw/WithdrawProgressView.swift b/TalerWallet1/Views/Withdraw/WithdrawProgressView.swift @@ -0,0 +1,45 @@ +/* + * 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 WithdrawProgressView: View { + let message: String + let action: () -> Void + + var cancelButton: some View { + Button("Cancel2") { + action() + } // dismiss the sheet + } + + var body: some View { // show Message with cancelButton + VStack { + Spacer() + ProgressView() + Spacer() + Text(message) + .font(.title) + Spacer() + Spacer() + }.navigationBarItems(leading: cancelButton) + } +} + +struct WithdrawProgressView_Previews: PreviewProvider { + static var previews: some View { + WithdrawProgressView(message: "message") {} + } +} diff --git a/TalerWallet1/Views/Withdraw/WithdrawTOSView.swift b/TalerWallet1/Views/Withdraw/WithdrawTOSView.swift @@ -0,0 +1,96 @@ +/* + * 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 SymLog + +struct WithdrawTOSView: View { + private let symLog = SymLogV() + var url: URL + @ObservedObject var model: WithdrawURIModel + +// @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + let navTitle = "Terms of Service" + var cancelButton: some View { + Button("Cancel3") { dismissTop() } + } + + var detailsForUri: WithdrawalDetailsForUri + @State var exchangeTOS: ExchangeTermsOfService? + @Binding var didAcceptTOS: Bool + + var body: some View { + let badURL = "Error in URL: \(url)" + let baseURL = detailsForUri.defaultExchangeBaseUrl + VStack { + switch model.withdrawState { + case .waitingForTOS: + WithdrawProgressView(message: baseURL ?? badURL) { + dismissTop() + }.navigationTitle("Loading " + navTitle) + case .receivedTOS: + Content(symLog: symLog, exchangeTOS: exchangeTOS) { + Task { + do { + _ = try await model.setExchangeTOSAccepted(baseURL!, etag: exchangeTOS!.currentEtag) + didAcceptTOS = true + } catch { + // TODO: Show Error + symLog.log(error.localizedDescription) + } + } + } + .navigationBarTitleDisplayMode(.large) // .inline + .navigationBarItems(leading: cancelButton) + .navigationTitle(navTitle) + default: + ErrorView() + } + }.task { + do { + let someTOS = try await model.loadExchangeTermsOfService(baseURL!) + exchangeTOS = someTOS + } catch { + // TODO: error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension WithdrawTOSView { + struct Content: View { + let symLog: SymLogV + var exchangeTOS: ExchangeTermsOfService? + var acceptAction: () -> () + + var body: some View { + Group { + if let tos = exchangeTOS { + let components = tos.content.components(separatedBy:"\n\n") + + List (components, id: \.self) { term in + Text(term) + } + AwesomeButton(title: "Accept") { + acceptAction() + }.padding(.vertical) + } else { + ErrorView() // TODO: ??? + } + } + } + } +} diff --git a/TalerWallet1/Views/Withdraw/WithdrawURIModel.swift b/TalerWallet1/Views/Withdraw/WithdrawURIModel.swift @@ -0,0 +1,213 @@ +/* + * 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 +import taler_swift +import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +enum WithdrawState { + case error + case waitingForUriDetails + case receivedUriDetails + case waitingForAmountDetails + case receivedAmountDetails + case waitingForTOS + case receivedTOS + case waitingForTOSAck + case receivedTOSAck + case waitingForWithdrAck + case receivedWithdrAck +} + +class WithdrawURIModel: WalletModel { + @Published var withdrawState: WithdrawState? +} + +// MARK: - +/// The result from getWithdrawalDetailsForUri +struct WithdrawalDetailsForUri: Decodable { + var amount: Amount + var defaultExchangeBaseUrl: String? + var possibleExchanges: [ExchangeListItem] +} +struct ExchangeListItem: Codable, Hashable { + var exchangeBaseUrl: String + var currency: String + var paytoUris: [String] + + public static func == (lhs: ExchangeListItem, rhs: ExchangeListItem) -> Bool { + return lhs.exchangeBaseUrl == rhs.exchangeBaseUrl && + lhs.currency == rhs.currency && + lhs.paytoUris == rhs.paytoUris + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(exchangeBaseUrl) + hasher.combine(currency) + hasher.combine(paytoUris) + } +} +/// A request to get an exchange's withdrawal details. +fileprivate struct GetWithdrawalDetailsForURI: WalletBackendFormattedRequest { + typealias Response = WithdrawalDetailsForUri + func operation() -> String { return "getWithdrawalDetailsForUri" } + func args() -> Args { return Args(talerWithdrawUri: talerWithdrawUri) } + + var talerWithdrawUri: String + struct Args: Encodable { + var talerWithdrawUri: String + } +} +// MARK: - +/// The result from getWithdrawalDetailsForAmount +struct WithdrawalDetailsForAmount: Decodable { + var tosAccepted: Bool + var amountRaw: Amount + var amountEffective: Amount +} +/// A request to get an exchange's withdrawal details. +fileprivate struct GetWithdrawalDetailsForAmount: WalletBackendFormattedRequest { + typealias Response = WithdrawalDetailsForAmount + func operation() -> String { return "getWithdrawalDetailsForAmount" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount) } + + var exchangeBaseUrl: String + var amount: Amount + struct Args: Encodable { + var exchangeBaseUrl: String + var amount: Amount + } +} +// MARK: - +struct ExchangeTermsOfService: Decodable { + var content: String + var currentEtag: String + var acceptedEtag: String? +} +/// A request to query an exchange's terms of service. +fileprivate struct GetExchangeTermsOfService: WalletBackendFormattedRequest { + typealias Response = ExchangeTermsOfService + func operation() -> String { return "getExchangeTos" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl) } + + var exchangeBaseUrl: String + struct Args: Encodable { + var exchangeBaseUrl: String + } +} +// MARK: - +/// A request to mark an exchange's terms of service as accepted. +fileprivate struct SetExchangeTOSAccepted: WalletBackendFormattedRequest { + struct Response: Decodable {} + func operation() -> String { return "setExchangeTosAccepted" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, etag: etag) } + + var exchangeBaseUrl: String + var etag: String + + struct Args: Encodable { + var exchangeBaseUrl: String + var etag: String + } +} +// MARK: - +struct BankConfirmation: Decodable { + var bankConfirmationUrl: String? +} +/// A request to accept a bank-integrated withdrawl. +fileprivate struct AcceptBankIntegratedWithdrawal: WalletBackendFormattedRequest { + typealias Response = BankConfirmation + func operation() -> String { return "acceptBankIntegratedWithdrawal" } + func args() -> Args { return Args(talerWithdrawUri: talerWithdrawUri, exchangeBaseUrl: exchangeBaseUrl) } + + var talerWithdrawUri: String + var exchangeBaseUrl: String + + struct Args: Encodable { + var talerWithdrawUri: String + var exchangeBaseUrl: String + } +} +// MARK: - +extension WithdrawURIModel { + /// load withdrawal details. Networking involved + @MainActor + func loadWithdrawalDetailsForURI(_ talerWithdrawUri: String) async throws -> WithdrawalDetailsForUri { + do { + withdrawState = .waitingForUriDetails + let request = GetWithdrawalDetailsForURI(talerWithdrawUri: talerWithdrawUri) + let response = try await sendRequest(request, ASYNCDELAY) + withdrawState = .receivedUriDetails + return response + } catch { + withdrawState = .error + throw error + } + } + @MainActor + func loadWithdrawalDetailsForAmount(_ detailsForUri: WithdrawalDetailsForUri) async throws -> WithdrawalDetailsForAmount { + do { + withdrawState = .waitingForAmountDetails + let baseURL = detailsForUri.defaultExchangeBaseUrl! + let request = GetWithdrawalDetailsForAmount(exchangeBaseUrl: baseURL, amount: detailsForUri.amount) + let response = try await sendRequest(request, ASYNCDELAY) + withdrawState = .receivedAmountDetails + return response + } catch { + withdrawState = .error + throw error + } + } + @MainActor + func loadExchangeTermsOfService(_ exchangeBaseUrl: String) async throws -> ExchangeTermsOfService { + do { + withdrawState = .waitingForTOS + let request = GetExchangeTermsOfService(exchangeBaseUrl: exchangeBaseUrl) + let response = try await sendRequest(request, ASYNCDELAY) + withdrawState = .receivedTOS + return response + } catch { + withdrawState = .error + throw error + } + } + @MainActor + func setExchangeTOSAccepted(_ exchangeBaseUrl: String, etag: String) async throws -> Decodable { + do { + withdrawState = .waitingForTOSAck + let request = SetExchangeTOSAccepted(exchangeBaseUrl: exchangeBaseUrl, etag: etag) + let response = try await sendRequest(request, ASYNCDELAY) + withdrawState = .receivedTOSAck + return response + } catch { + withdrawState = .error + throw error + } + } + @MainActor + func sendAcceptIntWithdrawal(_ exchangeBaseUrl: String, withdrawURL: String) async throws -> String? { + do { + withdrawState = .waitingForWithdrAck + let request = AcceptBankIntegratedWithdrawal(talerWithdrawUri: withdrawURL, exchangeBaseUrl: exchangeBaseUrl) + let response = try await sendRequest(request, ASYNCDELAY) + withdrawState = .receivedWithdrAck + return response.bankConfirmationUrl + } catch { + withdrawState = .error + throw error + } + } +} diff --git a/TalerWallet1/Views/Withdraw/WithdrawURIView.swift b/TalerWallet1/Views/Withdraw/WithdrawURIView.swift @@ -0,0 +1,103 @@ +/* + * 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 SymLog + +struct WithdrawURIView: View { + private let symLog = SymLogV() + var url: URL + @ObservedObject var viewModel: WithdrawURIModel + +// @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + let navTitle = "Withdraw" + var cancelButton: some View { + Button("Cancel1") { dismissTop() } + } + + @State var detailsForUri: WithdrawalDetailsForUri? + @State var detailsForAmount: WithdrawalDetailsForAmount? + @State var didAcceptTOS: Bool = false + + var body: some View { + let badURL = "Error in URL: \(url)" + VStack { + if viewModel.withdrawState == nil { + LoadingView(backButtonHidden: false) + } else { switch viewModel.withdrawState { + case .waitingForUriDetails: + let _ = symLog.vlog("waitingForUriDetails") + WithdrawProgressView(message: url.host ?? badURL) { dismissTop() } + .navigationTitle("Contacting Exchange") + case .waitingForAmountDetails: + let _ = symLog.vlog("waitingForAmountDetails") + WithdrawProgressView(message: detailsForUri!.defaultExchangeBaseUrl ?? badURL) { dismissTop() } + .navigationTitle("Found Exchange") + case .receivedAmountDetails, .waitingForTOS, .receivedTOS, .receivedTOSAck: + let _ = symLog.vlog("waitingForTOS") + if !didAcceptTOS { + WithdrawTOSView(url: url, model: viewModel, detailsForUri: detailsForUri!, didAcceptTOS: $didAcceptTOS) + } else { + // show Amount details and let user accept + WithdrawAcceptView(url: url, model: viewModel, detailsForAmount: detailsForAmount!, + baseURL: detailsForUri!.defaultExchangeBaseUrl!) + } + default: + symLog { + Content(symLog: symLog, viewModel: viewModel) + .navigationBarItems(leading: cancelButton) + .navigationTitle(navTitle) + } + } } + }.task { + do { // TODO: cancelled + symLog.log(".task") + detailsForUri = try await viewModel.loadWithdrawalDetailsForURI(url.absoluteString) + let baseURL = detailsForUri!.defaultExchangeBaseUrl + symLog.log("amount: \(detailsForUri!.amount), baseURL: \(baseURL)") + // TODO: let user choose exchange from array + detailsForAmount = try await viewModel.loadWithdrawalDetailsForAmount(detailsForUri!) + symLog.log("raw: \(detailsForAmount!.amountRaw), effective: \(detailsForAmount!.amountEffective)") + if detailsForAmount!.tosAccepted { + didAcceptTOS = true + } + } catch { + // TODO: error + } + } + } +} +// MARK: - +extension WithdrawURIView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var viewModel: WithdrawURIModel +// @EnvironmentObject var controller : Controller + + var body: some View { + Group { + Text("Hello") +// List(model.pendingOperations!, id: \.self) { pendingOp in +// PendingOpView(pendingOp: pendingOp) +// } +// .navigationBarTitleDisplayMode(.large) // .inline +// .refreshable { +// symLog?.log("refreshing") +// try? await reloadAction() // TODO: catch error +// } + } + } + } +}